From 5bf220fa5bc0540021d7bfeb77437a28f8971722 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sun, 12 Jan 2025 17:18:45 +0800 Subject: [PATCH] feat: picture notes editor --- package-lock.json | 30 +++ package.json | 1 + src/components/ImageGallery/index.tsx | 37 +-- src/components/PictureNoteCard/index.tsx | 26 +- ...{PostContent.tsx => NormalPostContent.tsx} | 48 +--- .../PostEditor/PicturePostContent.tsx | 233 ++++++++++++++++++ src/components/PostEditor/Uploader.tsx | 43 +++- src/components/PostEditor/index.tsx | 83 ++++--- src/components/Sidebar/PostButton.tsx | 6 +- src/components/ui/dialog.tsx | 3 +- src/components/ui/sheet.tsx | 14 +- src/components/ui/tabs.tsx | 53 ++++ src/i18n/en.ts | 4 +- src/i18n/zh.ts | 5 +- 14 files changed, 467 insertions(+), 119 deletions(-) rename src/components/PostEditor/{PostContent.tsx => NormalPostContent.tsx} (77%) create mode 100644 src/components/PostEditor/PicturePostContent.tsx create mode 100644 src/components/ui/tabs.tsx diff --git a/package-lock.json b/package-lock.json index 6338058..4097658 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", @@ -3088,6 +3089,35 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz", + "integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz", diff --git a/package.json b/package.json index 3a37ad5..995919d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index 1ed375d..d84b4ba 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { TImageInfo } from '@/types' import { ReactNode, useState } from 'react' +import { createPortal } from 'react-dom' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Image from '../Image' @@ -81,21 +82,27 @@ export default function ImageGallery({ return (
{imageContent} -
e.stopPropagation()}> - ({ src: url }))} - plugins={[Zoom]} - open={index >= 0} - close={() => setIndex(-1)} - controller={{ - closeOnBackdropClick: true, - closeOnPullUp: true, - closeOnPullDown: true - }} - styles={{ toolbar: { paddingTop: '2.25rem' } }} - /> -
+ {index >= 0 && + createPortal( +
e.stopPropagation()}> + ({ src: url }))} + plugins={[Zoom]} + open={index >= 0} + close={() => setIndex(-1)} + controller={{ + closeOnBackdropClick: true, + closeOnPullUp: true, + closeOnPullDown: true + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + /> +
, + document.body + )} {isNsfw && }
) diff --git a/src/components/PictureNoteCard/index.tsx b/src/components/PictureNoteCard/index.tsx index b810d75..5987a08 100644 --- a/src/components/PictureNoteCard/index.tsx +++ b/src/components/PictureNoteCard/index.tsx @@ -1,11 +1,9 @@ import { extractFirstPictureFromPictureEvent } from '@/lib/event' import { toNote } from '@/lib/link' +import { tagNameEquals } from '@/lib/tag' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { Event } from 'nostr-tools' -import Image from '../Image' -import UserAvatar from '../UserAvatar' -import Username from '../Username' import { useMemo } from 'react' import { embedded, @@ -13,6 +11,9 @@ import { embeddedNostrNpubRenderer, embeddedNostrProfileRenderer } from '../Embedded' +import Image from '../Image' +import UserAvatar from '../UserAvatar' +import Username from '../Username' export default function PictureNoteCard({ event, @@ -23,21 +24,20 @@ export default function PictureNoteCard({ }) { const { push } = useSecondaryPage() const firstImage = extractFirstPictureFromPictureEvent(event) - const content = useMemo( - () => - embedded(event.content, [ - embeddedNostrNpubRenderer, - embeddedNostrProfileRenderer, - embeddedHashtagRenderer - ]), - [event] - ) + const title = useMemo(() => { + const title = event.tags.find(tagNameEquals('title'))?.[1] ?? event.content + return embedded(title, [ + embeddedNostrNpubRenderer, + embeddedNostrProfileRenderer, + embeddedHashtagRenderer + ]) + }, [event]) if (!firstImage) return null return (
push(toNote(event))}> -
{content}
+
{title}
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/NormalPostContent.tsx similarity index 77% rename from src/components/PostEditor/PostContent.tsx rename to src/components/PostEditor/NormalPostContent.tsx index 0dcc1ba..001b3fd 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/NormalPostContent.tsx @@ -4,12 +4,7 @@ import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { StorageKey } from '@/constants' import { useToast } from '@/hooks/use-toast' -import { - createCommentDraftEvent, - createPictureNoteDraftEvent, - createShortTextNoteDraftEvent -} from '@/lib/draft-event' -import { extractImagesFromContent } from '@/lib/event' +import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { ChevronDown, LoaderCircle } from 'lucide-react' @@ -20,7 +15,7 @@ import Mentions from './Mentions' import Preview from './Preview' import Uploader from './Uploader' -export default function PostContent({ +export default function NormalPostContent({ defaultContent = '', parentEvent, close @@ -37,19 +32,12 @@ export default function PostContent({ const [posting, setPosting] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(false) - const [isPictureNote, setIsPictureNote] = useState(false) - const [hasImages, setHasImages] = useState(false) const canPost = !!content && !posting useEffect(() => { setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true') }, []) - useEffect(() => { - const { images } = extractImagesFromContent(content) - setHasImages(!!images && images.length > 0) - }, [content]) - const handleTextareaChange = (e: React.ChangeEvent) => { setContent(e.target.value) } @@ -69,18 +57,13 @@ export default function PostContent({ const relayList = await client.fetchRelayList(parentEvent.pubkey) additionalRelayUrls.push(...relayList.read.slice(0, 5)) } - if (isPictureNote && !hasImages) { - throw new Error(t('Picture note requires images')) - } const draftEvent = - isPictureNote && !parentEvent && hasImages - ? await createPictureNoteDraftEvent(content, pictureInfos, { addClientTag }) - : parentEvent && parentEvent.kind !== kinds.ShortTextNote - ? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag }) - : await createShortTextNoteDraftEvent(content, pictureInfos, { - parentEvent, - addClientTag - }) + parentEvent && parentEvent.kind !== kinds.ShortTextNote + ? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag }) + : await createShortTextNoteDraftEvent(content, pictureInfos, { + parentEvent, + addClientTag + }) await publish(draftEvent, additionalRelayUrls) setContent('') close() @@ -177,21 +160,6 @@ export default function PostContent({
{t('Show others this was sent via Jumble')}
- {!parentEvent && ( - <> -
- - -
-
- {t('A special note for picture-first clients like Olas')} -
- - )}
)}
diff --git a/src/components/PostEditor/PicturePostContent.tsx b/src/components/PostEditor/PicturePostContent.tsx new file mode 100644 index 0000000..b382c3c --- /dev/null +++ b/src/components/PostEditor/PicturePostContent.tsx @@ -0,0 +1,233 @@ +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { StorageKey } from '@/constants' +import { useToast } from '@/hooks/use-toast' +import { createPictureNoteDraftEvent } from '@/lib/draft-event' +import { useNostr } from '@/providers/NostrProvider' +import { ChevronDown, LoaderCircle, X } from 'lucide-react' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import Lightbox from 'yet-another-react-lightbox' +import Zoom from 'yet-another-react-lightbox/plugins/zoom' +import Image from '../Image' +import Mentions from './Mentions' +import Uploader from './Uploader' + +export default function PicturePostContent({ close }: { close: () => void }) { + const { t } = useTranslation() + const { toast } = useToast() + const { publish, checkLogin } = useNostr() + const [content, setContent] = useState('') + const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([]) + const [posting, setPosting] = useState(false) + const [showMoreOptions, setShowMoreOptions] = useState(false) + const [addClientTag, setAddClientTag] = useState(false) + const canPost = !!content && !posting && pictureInfos.length > 0 + + useEffect(() => { + setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true') + }, []) + + const handleTextareaChange = (e: React.ChangeEvent) => { + setContent(e.target.value) + } + + const post = async (e: React.MouseEvent) => { + e.stopPropagation() + checkLogin(async () => { + if (!canPost) { + close() + return + } + + setPosting(true) + try { + if (!pictureInfos.length) { + throw new Error(t('Picture note requires images')) + } + const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, { + addClientTag + }) + await publish(draftEvent) + setContent('') + close() + } catch (error) { + if (error instanceof AggregateError) { + error.errors.forEach((e) => + toast({ + variant: 'destructive', + title: t('Failed to post'), + description: e.message + }) + ) + } else if (error instanceof Error) { + toast({ + variant: 'destructive', + title: t('Failed to post'), + description: error.message + }) + } + console.error(error) + return + } finally { + setPosting(false) + } + toast({ + title: t('Post successful'), + description: t('Your post has been published') + }) + }) + } + + const onAddClientTagChange = (checked: boolean) => { + setAddClientTag(checked) + window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString()) + } + + return ( +
+
+ {t('A special note for picture-first clients like Olas')} +
+ +