mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 17:51:16 +02:00
initial beta commit
This commit is contained in:
@@ -9,6 +9,7 @@ import { SectionIcon, GridIcon } from '@radix-ui/react-icons'
|
||||
import ProfileQuickViewFeed from "@/components/ProfileQuickViewFeed";
|
||||
import ProfileTextFeed from "@/components/ProfileTextFeed";
|
||||
import { NostrProvider } from "nostr-react";
|
||||
import ProfileGalleryViewFeed from "@/components/ProfileGalleryViewFeed";
|
||||
|
||||
export default function ProfilePage() {
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function ProfilePage() {
|
||||
const relayUrls = [
|
||||
"wss://relay.lumina.rocks",
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<NostrProvider relayUrls={relayUrls} debug={false}>
|
||||
@@ -37,6 +38,7 @@ export default function ProfilePage() {
|
||||
<TabsTrigger value="QuickView"><GridIcon /></TabsTrigger>
|
||||
<TabsTrigger value="ProfileFeed"><SectionIcon /></TabsTrigger>
|
||||
<TabsTrigger value="ProfileTextFeed">Notes</TabsTrigger>
|
||||
{/* <TabsTrigger value="Gallery">Gallery</TabsTrigger> */}
|
||||
</TabsList>
|
||||
<TabsContent value="QuickView">
|
||||
<ProfileQuickViewFeed pubkey={pubkey.toString()} />
|
||||
@@ -47,6 +49,9 @@ export default function ProfilePage() {
|
||||
<TabsContent value="ProfileTextFeed">
|
||||
<ProfileTextFeed pubkey={pubkey.toString()} />
|
||||
</TabsContent>
|
||||
{/* <TabsContent value="Gallery">
|
||||
<ProfileGalleryViewFeed pubkey={pubkey.toString()} />
|
||||
</TabsContent> */}
|
||||
</Tabs>
|
||||
</div>
|
||||
</NostrProvider>
|
||||
|
||||
46
lumina/app/upload/page.tsx
Normal file
46
lumina/app/upload/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import Head from "next/head";
|
||||
import ProfileInfoCard from "@/components/ProfileInfoCard";
|
||||
import ProfileFeed from "@/components/ProfileFeed";
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Event, NostrEvent, finalizeEvent, nip19 } from "nostr-tools";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { SectionIcon, GridIcon } from '@radix-ui/react-icons'
|
||||
import TagFeed from "@/components/TagFeed";
|
||||
import { NostrProvider, useNostr } from "nostr-react";
|
||||
import { FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import UploadComponent from "@/components/UploadComponent";
|
||||
|
||||
export default function UploadPage() {
|
||||
|
||||
// check if pubkey contains "npub"
|
||||
// if so, then we need to convert it to a pubkey
|
||||
// if (pubkey.includes("npub")) {
|
||||
// // convert npub to pubkey
|
||||
// pubkey = nip19.decode(pubkey.toString()).data.toString()
|
||||
// }
|
||||
|
||||
const relayUrls = [
|
||||
"wss://relay.lumina.rocks",
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NostrProvider relayUrls={relayUrls} debug={false}>
|
||||
<Head>
|
||||
<title>LUMINA.rocks</title>
|
||||
<meta name="description" content="Yet another nostr web ui" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="py-6 px-6">
|
||||
<UploadComponent />
|
||||
</div>
|
||||
</NostrProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v0 by Vercel.
|
||||
* @see https://v0.dev/t/mwaJmHMv0vd
|
||||
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
|
||||
*/
|
||||
import { BellIcon, GlobeIcon, HomeIcon, RowsIcon } from "@radix-ui/react-icons"
|
||||
import { BellIcon, GlobeIcon, HomeIcon, RowsIcon, UploadIcon } from "@radix-ui/react-icons"
|
||||
import Link from "next/link"
|
||||
import { JSX, SVGProps, useEffect, useState } from "react"
|
||||
import { FormEvent, JSX, SVGProps, useEffect, useState } from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { SearchIcon, Upload } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { useNostr } from "nostr-react";
|
||||
|
||||
export default function BottomBar() {
|
||||
|
||||
const router = useRouter();
|
||||
const [pubkey, setPubkey] = useState<null | string>(null);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
return setPubkey(window.localStorage.getItem('pubkey') ?? null);
|
||||
if (typeof window !== 'undefined') {
|
||||
setPubkey(window.localStorage.getItem('pubkey') ?? null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pathname = usePathname();
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const isActive = (path: string, currentPath: string) => currentPath === path ? 'text-purple-500' : '';
|
||||
|
||||
return (
|
||||
@@ -36,10 +47,12 @@ export default function BottomBar() {
|
||||
<span className="sr-only">Follower Feed</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/global', pathname)}`} href="/global">
|
||||
<GlobeIcon className={`h-6 w-6`} />
|
||||
<span className="sr-only">Global</span>
|
||||
</Link>
|
||||
{pubkey && window.localStorage.getItem('loginType') != 'readOnly_npub' && (
|
||||
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/upload', pathname)}`} href="/upload">
|
||||
<UploadIcon className={`h-6 w-6`} />
|
||||
<span className="sr-only">Upload</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/search', pathname)}`} href="/search">
|
||||
<SearchIcon className={`h-6 w-6`} />
|
||||
<span className="sr-only">Search</span>
|
||||
|
||||
@@ -43,8 +43,8 @@ const NoteCard: React.FC<CommentCardProps> = ({ pubkey, text, eventId, tags, eve
|
||||
});
|
||||
|
||||
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey);
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif)/g, '');
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g, '');
|
||||
const createdAt = new Date(event.created_at * 1000);
|
||||
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
|
||||
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;
|
||||
|
||||
@@ -32,7 +32,7 @@ const FollowerFeed: React.FC<FollowerFeedProps> = ({ pubkey }) => {
|
||||
|
||||
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
|
||||
// filter events with regex that checks for png, jpg, or gif
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|jpeg)/g)?.[0]);
|
||||
|
||||
// now filter all events with a tag[0] == t and tag[1] == nsfw
|
||||
filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));
|
||||
|
||||
@@ -31,7 +31,7 @@ const FollowerQuickViewFeed: React.FC<FollowerQuickViewFeedProps> = ({ pubkey })
|
||||
},
|
||||
});
|
||||
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
|
||||
// filter out all replies (tag[0] == e)
|
||||
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));
|
||||
|
||||
|
||||
55
lumina/components/GalleryCard.tsx
Normal file
55
lumina/components/GalleryCard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useProfile } from "nostr-react";
|
||||
import {
|
||||
nip19,
|
||||
} from "nostr-tools";
|
||||
import {
|
||||
Card,
|
||||
SmallCardContent,
|
||||
} from "@/components/ui/card"
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { PlayIcon, StackIcon, VideoIcon } from '@radix-ui/react-icons';
|
||||
|
||||
interface GalleryCardProps {
|
||||
pubkey: string;
|
||||
eventId: string;
|
||||
imageUrl: string;
|
||||
linkToNote: boolean;
|
||||
}
|
||||
|
||||
const GalleryCard: React.FC<GalleryCardProps> = ({ pubkey, eventId, imageUrl, linkToNote }) => {
|
||||
const { data: userData } = useProfile({
|
||||
pubkey,
|
||||
});
|
||||
|
||||
const encodedNoteId = nip19.noteEncode(eventId);
|
||||
|
||||
const card = (
|
||||
<Card>
|
||||
<SmallCardContent>
|
||||
<div>
|
||||
<div className='d-flex justify-content-center align-items-center'>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img src={imageUrl} className='rounded lg:rounded-lg' style={{ maxWidth: '100%', maxHeight: '75vh', objectFit: 'contain', margin: 'auto' }} alt={eventId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmallCardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkToNote ? (
|
||||
<Link href={`/note/${encodedNoteId}`}>
|
||||
{card}
|
||||
</Link>
|
||||
) : (
|
||||
card
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GalleryCard;
|
||||
@@ -16,7 +16,7 @@ const GlobalFeed: React.FC = () => {
|
||||
|
||||
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
|
||||
// filter events with regex that checks for png, jpg, or gif
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
|
||||
|
||||
// now filter all events with a tag[0] == t and tag[1] == nsfw
|
||||
// filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));
|
||||
|
||||
@@ -49,9 +49,9 @@ const NoteCard: React.FC<NoteCardProps> = ({ pubkey, text, eventId, tags, event,
|
||||
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey);
|
||||
// text = text.replaceAll('\n', '<br />');
|
||||
text = text.replaceAll('\n', ' ');
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
|
||||
const videoSrc = text.match(/https?:\/\/[^ ]*\.(mp4|webm|mov)/g);
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g, '');
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g, '');
|
||||
const createdAt = new Date(event.created_at * 1000);
|
||||
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
|
||||
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;
|
||||
|
||||
@@ -20,7 +20,7 @@ const ProfileFeed: React.FC<ProfileFeedProps> = ({ pubkey }) => {
|
||||
},
|
||||
});
|
||||
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g)?.[0]);
|
||||
// filter out all replies (tag[0] == e)
|
||||
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));
|
||||
|
||||
|
||||
61
lumina/components/ProfileGalleryViewFeed.tsx
Normal file
61
lumina/components/ProfileGalleryViewFeed.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useRef } from "react";
|
||||
import { useNostrEvents } from "nostr-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import GalleryCard from "./GalleryCard";
|
||||
|
||||
interface ProfileGalleryViewFeedProps {
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
const ProfileGalleryViewFeed: React.FC<ProfileGalleryViewFeedProps> = ({ pubkey }) => {
|
||||
const now = useRef(new Date()); // Make sure current time isn't re-rendered
|
||||
|
||||
const { isLoading, events } = useNostrEvents({
|
||||
filter: {
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
kinds: [10011],
|
||||
},
|
||||
});
|
||||
|
||||
const imagesAndIds = events.map((event) => {
|
||||
return {
|
||||
id: event.tags.filter((tag) => tag[0] === 'G').map((tag) => tag[1]),
|
||||
images: event.tags.filter((tag) => tag[0] === 'G').map((tag) => tag[2])
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{imagesAndIds.length === 0 && isLoading ? (
|
||||
<>
|
||||
<div>
|
||||
<Skeleton className="h-[125px] rounded-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-[125px] rounded-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-[125px] rounded-xl" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
imagesAndIds.map((galleryEntry) => (
|
||||
galleryEntry.images.map((imageUrl, index) => (
|
||||
<GalleryCard
|
||||
pubkey={pubkey}
|
||||
key={`${galleryEntry.id[index]}-${index}`}
|
||||
eventId={galleryEntry.id[index]}
|
||||
imageUrl={imageUrl}
|
||||
linkToNote={true}
|
||||
/>
|
||||
))
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileGalleryViewFeed;
|
||||
@@ -20,7 +20,7 @@ const ProfileQuickViewFeed: React.FC<ProfileQuickViewFeedProps> = ({ pubkey }) =
|
||||
},
|
||||
});
|
||||
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g)?.[0]);
|
||||
// filter out all replies (tag[0] == e)
|
||||
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const ProfileTextFeed: React.FC<ProfileTextFeedProps> = ({ pubkey }) => {
|
||||
});
|
||||
|
||||
// filter out all images since we only want text messages
|
||||
let filteredEvents = events.filter((event) => !event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => !event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
|
||||
// filter out all replies (tag[0] == e)
|
||||
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ const QuickViewNoteCard: React.FC<NoteCardProps> = ({ pubkey, text, eventId, tag
|
||||
|
||||
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey);
|
||||
text = text.replaceAll('\n', ' ');
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
|
||||
const videoSrc = text.match(/https?:\/\/[^ ]*\.(mp4|webm|mov)/g);
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g, '');
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g, '');
|
||||
const createdAt = new Date(event.created_at * 1000);
|
||||
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
|
||||
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;
|
||||
|
||||
@@ -16,7 +16,7 @@ const ReelFeed: React.FC = () => {
|
||||
|
||||
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
|
||||
// filter events with regex that checks for png, jpg, or gif
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
|
||||
|
||||
// now filter all events with a tag[0] == t and tag[1] == nsfw
|
||||
// filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));
|
||||
|
||||
@@ -21,7 +21,7 @@ const TagFeed: React.FC<TagFeedProps> = ({tag}) => {
|
||||
|
||||
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
|
||||
// filter events with regex that checks for png, jpg, or gif
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm)/g)?.[0]);
|
||||
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|jpeg)/g)?.[0]);
|
||||
|
||||
// now filter all events with a tag[0] == t and tag[1] == nsfw
|
||||
filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));
|
||||
|
||||
@@ -41,8 +41,8 @@ const TrendingImage: React.FC<TrendingImageProps> = ({ eventId, pubkey }) => {
|
||||
const createdAt = events && events.length > 0 ? new Date(events[0].created_at * 1000) : new Date();
|
||||
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || npubShortened;
|
||||
text = text.replaceAll('\n', ' ');
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif)/g, '');
|
||||
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
|
||||
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g, '');
|
||||
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
|
||||
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;
|
||||
|
||||
|
||||
231
lumina/components/UploadComponent.tsx
Normal file
231
lumina/components/UploadComponent.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useNostr } from 'nostr-react';
|
||||
import { finalizeEvent, nip19, NostrEvent } from 'nostr-tools';
|
||||
import React, { ChangeEvent, FormEvent, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import { ReloadIcon } from '@radix-ui/react-icons';
|
||||
import { Label } from './ui/label';
|
||||
import { Input } from './ui/input';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
|
||||
const UploadComponent: React.FC = () => {
|
||||
|
||||
const { publish } = useNostr();
|
||||
const { createHash } = require('crypto');
|
||||
const loginType = typeof window !== 'undefined' ? window.localStorage.getItem('loginType') : null;
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
|
||||
// Optional: Bereinigung alter URLs
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const desc = formData.get('description') as string;
|
||||
const file = formData.get('file') as File;
|
||||
let sha256 = '';
|
||||
let finalNoteContent = desc;
|
||||
let finalFileUrl = '';
|
||||
console.log('File:', file);
|
||||
|
||||
if (!desc && !file.size) {
|
||||
alert('Please enter a description and/or upload a file');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// get every hashtag in desc and cut off the # symbol
|
||||
let hashtags: string[] = desc.match(/#[a-zA-Z0-9]+/g) || [];
|
||||
if (hashtags) {
|
||||
hashtags = hashtags.map((hashtag) => hashtag.slice(1));
|
||||
}
|
||||
|
||||
|
||||
// If file is is preent, upload it to the media server
|
||||
if (file) {
|
||||
const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
const hashBuffer = createHash('sha256').update(Buffer.from(arrayBuffer)).digest();
|
||||
sha256 = hashBuffer.toString('hex');
|
||||
|
||||
const unixNow = () => Math.floor(Date.now() / 1000);
|
||||
const newExpirationValue = () => (unixNow() + 60 * 5).toString();
|
||||
|
||||
const pubkey = window.localStorage.getItem('pubkey');
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Create auth event for blossom auth via nostr
|
||||
let authEvent = {
|
||||
kind: 24242,
|
||||
content: desc,
|
||||
created_at: createdAt,
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', sha256],
|
||||
['expiration', newExpirationValue()],
|
||||
],
|
||||
};
|
||||
|
||||
console.log(authEvent);
|
||||
|
||||
// Sign auth event
|
||||
let authEventSigned = {};
|
||||
if (loginType === 'extension') {
|
||||
authEventSigned = await window.nostr.signEvent(authEvent);
|
||||
} else if (loginType === 'amber') {
|
||||
// TODO: Sign event with amber
|
||||
alert('Signing with Amber is not implemented yet, sorry!');
|
||||
} else if (loginType === 'raw_nsec') {
|
||||
if (typeof window !== 'undefined') {
|
||||
let nsecStr = null;
|
||||
nsecStr = window.localStorage.getItem('nsec');
|
||||
if (nsecStr != null) {
|
||||
authEventSigned = finalizeEvent(authEvent, hexToBytes(nsecStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(authEventSigned);
|
||||
|
||||
// Actually upload the file
|
||||
await fetch('https://media.lumina.rocks/upload', {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: { authorization: 'Nostr ' + btoa(JSON.stringify(authEventSigned)) },
|
||||
}).then(async (res) => {
|
||||
if (res.ok) {
|
||||
let responseText = await res.text();
|
||||
let responseJson = JSON.parse(responseText);
|
||||
finalFileUrl = responseJson.url;
|
||||
} else {
|
||||
alert(await res.text());
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let noteTags = hashtags.map((tag) => ['t', tag]);
|
||||
|
||||
// If we have a file, add the file url to the note content
|
||||
// and also to the note tags imeta
|
||||
// "tags": [
|
||||
// [
|
||||
// "imeta",
|
||||
// "url https://nostr.build/i/my-image.jpg",
|
||||
// "m image/jpeg",
|
||||
// "blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$",
|
||||
// "dim 3024x4032",
|
||||
// "alt A scenic photo overlooking the coast of Costa Rica",
|
||||
// "x <sha256 hash as specified in NIP 94>",
|
||||
// "fallback https://nostrcheck.me/alt1.jpg",
|
||||
// "fallback https://void.cat/alt1.jpg"
|
||||
// ]
|
||||
// ]
|
||||
if (finalFileUrl) {
|
||||
// convert file into image
|
||||
const image = new Image();
|
||||
image.src = URL.createObjectURL(file);
|
||||
|
||||
finalNoteContent = finalFileUrl + ' ' + desc;
|
||||
noteTags.push(['imeta', 'url ' + finalFileUrl, 'm ' + file.type, 'x ' + sha256, 'ox ' + sha256]);
|
||||
}
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
||||
// Create the actual note
|
||||
let noteEvent = {
|
||||
kind: 1,
|
||||
content: finalNoteContent,
|
||||
created_at: createdAt,
|
||||
tags: noteTags,
|
||||
};
|
||||
|
||||
let signedEvent: NostrEvent | null = null;
|
||||
|
||||
// Sign the actual note
|
||||
if (loginType === 'extension') {
|
||||
signedEvent = await window.nostr.signEvent(noteEvent);
|
||||
} else if (loginType === 'amber') {
|
||||
// TODO: Sign event with amber
|
||||
alert('Signing with Amber is not implemented yet, sorry!');
|
||||
} else if (loginType === 'raw_nsec') {
|
||||
if (typeof window !== 'undefined') {
|
||||
let nsecStr = null;
|
||||
nsecStr = window.localStorage.getItem('nsec');
|
||||
if (nsecStr != null) {
|
||||
signedEvent = finalizeEvent(noteEvent, hexToBytes(nsecStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the got a signed event, publish it to nostr
|
||||
if (signedEvent) {
|
||||
console.log("final Event: ")
|
||||
console.log(signedEvent)
|
||||
publish(signedEvent);
|
||||
}
|
||||
|
||||
// Redirect to the note
|
||||
setIsLoading(false);
|
||||
if (signedEvent != null) {
|
||||
window.location.href = '/note/' + nip19.noteEncode(signedEvent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<Textarea name="description" rows={6} placeholder="Your description" id="description" className="w-full"></Textarea>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Image Upload</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Input id="file" name='file' type="file" accept='image/*' onChange={handleFileChange} />
|
||||
</div>
|
||||
{previewUrl && <img src={previewUrl} alt="Preview" className="w-full pt-4" />}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{isLoading ? (
|
||||
<Button className='w-full' disabled>Uploading.. <ReloadIcon className="m-2 h-4 w-4 animate-spin" /></Button>
|
||||
) : (
|
||||
<Button className='w-full'>Upload</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UploadComponent;
|
||||
Reference in New Issue
Block a user