feat: picture notes editor
This commit is contained in:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"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": {
|
"node_modules/@radix-ui/react-toast": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.4",
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { TImageInfo } from '@/types'
|
import { TImageInfo } from '@/types'
|
||||||
import { ReactNode, useState } from 'react'
|
import { ReactNode, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import Lightbox from 'yet-another-react-lightbox'
|
import Lightbox from 'yet-another-react-lightbox'
|
||||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||||
import Image from '../Image'
|
import Image from '../Image'
|
||||||
@@ -81,21 +82,27 @@ export default function ImageGallery({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('relative w-fit max-w-full', className)}>
|
<div className={cn('relative w-fit max-w-full', className)}>
|
||||||
{imageContent}
|
{imageContent}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
{index >= 0 &&
|
||||||
<Lightbox
|
createPortal(
|
||||||
index={index}
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
slides={images.map(({ url }) => ({ src: url }))}
|
<Lightbox
|
||||||
plugins={[Zoom]}
|
index={index}
|
||||||
open={index >= 0}
|
slides={images.map(({ url }) => ({ src: url }))}
|
||||||
close={() => setIndex(-1)}
|
plugins={[Zoom]}
|
||||||
controller={{
|
open={index >= 0}
|
||||||
closeOnBackdropClick: true,
|
close={() => setIndex(-1)}
|
||||||
closeOnPullUp: true,
|
controller={{
|
||||||
closeOnPullDown: true
|
closeOnBackdropClick: true,
|
||||||
}}
|
closeOnPullUp: true,
|
||||||
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
closeOnPullDown: true
|
||||||
/>
|
}}
|
||||||
</div>
|
styles={{
|
||||||
|
toolbar: { paddingTop: '2.25rem' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { extractFirstPictureFromPictureEvent } from '@/lib/event'
|
import { extractFirstPictureFromPictureEvent } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import Image from '../Image'
|
|
||||||
import UserAvatar from '../UserAvatar'
|
|
||||||
import Username from '../Username'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
embedded,
|
embedded,
|
||||||
@@ -13,6 +11,9 @@ import {
|
|||||||
embeddedNostrNpubRenderer,
|
embeddedNostrNpubRenderer,
|
||||||
embeddedNostrProfileRenderer
|
embeddedNostrProfileRenderer
|
||||||
} from '../Embedded'
|
} from '../Embedded'
|
||||||
|
import Image from '../Image'
|
||||||
|
import UserAvatar from '../UserAvatar'
|
||||||
|
import Username from '../Username'
|
||||||
|
|
||||||
export default function PictureNoteCard({
|
export default function PictureNoteCard({
|
||||||
event,
|
event,
|
||||||
@@ -23,21 +24,20 @@ export default function PictureNoteCard({
|
|||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const firstImage = extractFirstPictureFromPictureEvent(event)
|
const firstImage = extractFirstPictureFromPictureEvent(event)
|
||||||
const content = useMemo(
|
const title = useMemo(() => {
|
||||||
() =>
|
const title = event.tags.find(tagNameEquals('title'))?.[1] ?? event.content
|
||||||
embedded(event.content, [
|
return embedded(title, [
|
||||||
embeddedNostrNpubRenderer,
|
embeddedNostrNpubRenderer,
|
||||||
embeddedNostrProfileRenderer,
|
embeddedNostrProfileRenderer,
|
||||||
embeddedHashtagRenderer
|
embeddedHashtagRenderer
|
||||||
]),
|
])
|
||||||
[event]
|
}, [event])
|
||||||
)
|
|
||||||
if (!firstImage) return null
|
if (!firstImage) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-1 cursor-pointer', className)} onClick={() => push(toNote(event))}>
|
<div className={cn('space-y-1 cursor-pointer', className)} onClick={() => push(toNote(event))}>
|
||||||
<Image className="rounded-lg w-full aspect-[6/8]" image={firstImage} />
|
<Image className="rounded-lg w-full aspect-[6/8]" image={firstImage} />
|
||||||
<div className="line-clamp-2 px-2">{content}</div>
|
<div className="line-clamp-2 px-2">{title}</div>
|
||||||
<div className="flex items-center gap-2 px-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<UserAvatar userId={event.pubkey} size="xSmall" />
|
<UserAvatar userId={event.pubkey} size="xSmall" />
|
||||||
<Username userId={event.pubkey} className="text-sm text-muted-foreground truncate" />
|
<Username userId={event.pubkey} className="text-sm text-muted-foreground truncate" />
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { StorageKey } from '@/constants'
|
import { StorageKey } from '@/constants'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import {
|
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||||
createCommentDraftEvent,
|
|
||||||
createPictureNoteDraftEvent,
|
|
||||||
createShortTextNoteDraftEvent
|
|
||||||
} from '@/lib/draft-event'
|
|
||||||
import { extractImagesFromContent } from '@/lib/event'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
||||||
@@ -20,7 +15,7 @@ import Mentions from './Mentions'
|
|||||||
import Preview from './Preview'
|
import Preview from './Preview'
|
||||||
import Uploader from './Uploader'
|
import Uploader from './Uploader'
|
||||||
|
|
||||||
export default function PostContent({
|
export default function NormalPostContent({
|
||||||
defaultContent = '',
|
defaultContent = '',
|
||||||
parentEvent,
|
parentEvent,
|
||||||
close
|
close
|
||||||
@@ -37,19 +32,12 @@ export default function PostContent({
|
|||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||||
const [addClientTag, setAddClientTag] = useState(false)
|
const [addClientTag, setAddClientTag] = useState(false)
|
||||||
const [isPictureNote, setIsPictureNote] = useState(false)
|
|
||||||
const [hasImages, setHasImages] = useState(false)
|
|
||||||
const canPost = !!content && !posting
|
const canPost = !!content && !posting
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
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<HTMLTextAreaElement>) => {
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setContent(e.target.value)
|
setContent(e.target.value)
|
||||||
}
|
}
|
||||||
@@ -69,18 +57,13 @@ export default function PostContent({
|
|||||||
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
||||||
additionalRelayUrls.push(...relayList.read.slice(0, 5))
|
additionalRelayUrls.push(...relayList.read.slice(0, 5))
|
||||||
}
|
}
|
||||||
if (isPictureNote && !hasImages) {
|
|
||||||
throw new Error(t('Picture note requires images'))
|
|
||||||
}
|
|
||||||
const draftEvent =
|
const draftEvent =
|
||||||
isPictureNote && !parentEvent && hasImages
|
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||||
? await createPictureNoteDraftEvent(content, pictureInfos, { addClientTag })
|
? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag })
|
||||||
: parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
: await createShortTextNoteDraftEvent(content, pictureInfos, {
|
||||||
? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag })
|
parentEvent,
|
||||||
: await createShortTextNoteDraftEvent(content, pictureInfos, {
|
addClientTag
|
||||||
parentEvent,
|
})
|
||||||
addClientTag
|
|
||||||
})
|
|
||||||
await publish(draftEvent, additionalRelayUrls)
|
await publish(draftEvent, additionalRelayUrls)
|
||||||
setContent('')
|
setContent('')
|
||||||
close()
|
close()
|
||||||
@@ -177,21 +160,6 @@ export default function PostContent({
|
|||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
{t('Show others this was sent via Jumble')}
|
{t('Show others this was sent via Jumble')}
|
||||||
</div>
|
</div>
|
||||||
{!parentEvent && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label htmlFor="picture-note">{t('Picture note')}</Label>
|
|
||||||
<Switch
|
|
||||||
id="picture-note"
|
|
||||||
checked={isPictureNote}
|
|
||||||
onCheckedChange={setIsPictureNote}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{t('A special note for picture-first clients like Olas')}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 items-center justify-around sm:hidden">
|
<div className="flex gap-2 items-center justify-around sm:hidden">
|
||||||
233
src/components/PostEditor/PicturePostContent.tsx
Normal file
233
src/components/PostEditor/PicturePostContent.tsx
Normal file
@@ -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<HTMLTextAreaElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('A special note for picture-first clients like Olas')}
|
||||||
|
</div>
|
||||||
|
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} />
|
||||||
|
<Textarea
|
||||||
|
className="h-32"
|
||||||
|
onChange={handleTextareaChange}
|
||||||
|
value={content}
|
||||||
|
placeholder={t('Write something...')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-foreground gap-0 px-0"
|
||||||
|
onClick={() => setShowMoreOptions((pre) => !pre)}
|
||||||
|
>
|
||||||
|
{t('More options')}
|
||||||
|
<ChevronDown className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Mentions content={content} />
|
||||||
|
<div className="flex gap-2 items-center max-sm:hidden">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||||
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
|
{t('Post')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showMoreOptions && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
|
||||||
|
<Switch
|
||||||
|
id="add-client-tag"
|
||||||
|
checked={addClientTag}
|
||||||
|
onCheckedChange={onAddClientTagChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t('Show others this was sent via Jumble')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 items-center justify-around sm:hidden">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
|
||||||
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
|
{t('Post')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PictureUploader({
|
||||||
|
pictureInfos,
|
||||||
|
setPictureInfos
|
||||||
|
}: {
|
||||||
|
pictureInfos: { url: string; tags: string[][] }[]
|
||||||
|
setPictureInfos: Dispatch<
|
||||||
|
SetStateAction<
|
||||||
|
{
|
||||||
|
url: string
|
||||||
|
tags: string[][]
|
||||||
|
}[]
|
||||||
|
>
|
||||||
|
>
|
||||||
|
}) {
|
||||||
|
const [index, setIndex] = useState(-1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{pictureInfos.map(({ url }, index) => (
|
||||||
|
<div className="relative" key={`${index}-${url}`}>
|
||||||
|
<Image
|
||||||
|
image={{ url }}
|
||||||
|
className="aspect-square w-full rounded-lg"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIndex(index)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 rounded-full w-6 h-6 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setPictureInfos((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Uploader
|
||||||
|
variant="big"
|
||||||
|
onUploadSuccess={({ url, tags }) => {
|
||||||
|
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{index >= 0 &&
|
||||||
|
createPortal(
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Lightbox
|
||||||
|
index={index}
|
||||||
|
slides={pictureInfos.map(({ url }) => ({ src: url }))}
|
||||||
|
plugins={[Zoom]}
|
||||||
|
open={index >= 0}
|
||||||
|
close={() => setIndex(-1)}
|
||||||
|
controller={{
|
||||||
|
closeOnBackdropClick: true,
|
||||||
|
closeOnPullUp: true,
|
||||||
|
closeOnPullDown: true
|
||||||
|
}}
|
||||||
|
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { ImageUp, LoaderCircle } from 'lucide-react'
|
import { ImageUp, Loader, LoaderCircle, Plus } from 'lucide-react'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export default function Uploader({
|
export default function Uploader({
|
||||||
onUploadSuccess
|
onUploadSuccess,
|
||||||
|
variant = 'button'
|
||||||
}: {
|
}: {
|
||||||
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
onUploadSuccess: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||||
|
variant?: 'button' | 'big'
|
||||||
}) {
|
}) {
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const { signHttpAuth } = useNostr()
|
const { signHttpAuth } = useNostr()
|
||||||
@@ -62,20 +65,46 @@ export default function Uploader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
const handleUploadClick = () => {
|
||||||
fileInputRef.current?.click()
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '' // clear the value so that the same file can be uploaded again
|
||||||
|
fileInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'button') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
|
||||||
|
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept="image/*,video/*,audio/*"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
|
<div
|
||||||
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
className={cn(
|
||||||
</Button>
|
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
|
||||||
|
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
|
||||||
|
)}
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
>
|
||||||
|
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
accept="image/*,video/*,audio/*"
|
accept="image/*"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle
|
|
||||||
} from '@/components/ui/drawer'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { Dispatch } from 'react'
|
import { Dispatch, useMemo } from 'react'
|
||||||
import PostContent from './PostContent'
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../ui/sheet'
|
||||||
|
import NormalPostContent from './NormalPostContent'
|
||||||
|
import PicturePostContent from './PicturePostContent'
|
||||||
import Title from './Title'
|
import Title from './Title'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function PostEditor({
|
export default function PostEditor({
|
||||||
defaultContent = '',
|
defaultContent = '',
|
||||||
@@ -30,32 +27,58 @@ export default function PostEditor({
|
|||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
return parentEvent ? (
|
||||||
|
<NormalPostContent
|
||||||
|
defaultContent={defaultContent}
|
||||||
|
parentEvent={parentEvent}
|
||||||
|
close={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="normal" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="normal">{t('Normal Post')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="picture">{t('Picture Post')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="normal">
|
||||||
|
<NormalPostContent
|
||||||
|
defaultContent={defaultContent}
|
||||||
|
parentEvent={parentEvent}
|
||||||
|
close={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="picture">
|
||||||
|
<PicturePostContent close={() => setOpen(false)} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}, [parentEvent])
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen} modal={false}>
|
||||||
<DrawerContent className="h-full">
|
<SheetContent className="h-full w-full p-0 border-none" side="bottom" hideClose>
|
||||||
<DrawerHeader>
|
<ScrollArea className="px-4 h-full max-h-screen">
|
||||||
<DrawerTitle className="text-start">
|
<div className="space-y-4 px-2 py-6">
|
||||||
<Title parentEvent={parentEvent} />
|
<SheetHeader>
|
||||||
</DrawerTitle>
|
<SheetTitle className="text-start">
|
||||||
<DrawerDescription className="hidden" />
|
<Title parentEvent={parentEvent} />
|
||||||
</DrawerHeader>
|
</SheetTitle>
|
||||||
<div className="overflow-auto py-2 px-4">
|
<SheetDescription className="hidden" />
|
||||||
<PostContent
|
</SheetHeader>
|
||||||
defaultContent={defaultContent}
|
{content}
|
||||||
parentEvent={parentEvent}
|
</div>
|
||||||
close={() => setOpen(false)}
|
</ScrollArea>
|
||||||
/>
|
</SheetContent>
|
||||||
</div>
|
</Sheet>
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen} modal={false}>
|
||||||
<DialogContent className="p-0" withoutClose>
|
<DialogContent className="p-0" withoutClose>
|
||||||
<ScrollArea className="px-4 h-full max-h-screen">
|
<ScrollArea className="px-4 h-full max-h-screen">
|
||||||
<div className="space-y-4 px-2 py-6">
|
<div className="space-y-4 px-2 py-6">
|
||||||
@@ -65,11 +88,7 @@ export default function PostEditor({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="hidden" />
|
<DialogDescription className="hidden" />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<PostContent
|
{content}
|
||||||
defaultContent={defaultContent}
|
|
||||||
parentEvent={parentEvent}
|
|
||||||
close={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default function PostButton() {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="pt-4">
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="New post"
|
title="New post"
|
||||||
description="Post"
|
description="Post"
|
||||||
@@ -16,11 +16,11 @@ export default function PostButton() {
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
}}
|
}}
|
||||||
variant="default"
|
variant="default"
|
||||||
className="bg-primary"
|
className="bg-primary xl:justify-center"
|
||||||
>
|
>
|
||||||
<PencilLine strokeWidth={3} />
|
<PencilLine strokeWidth={3} />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
<PostEditor open={open} setOpen={setOpen} />
|
<PostEditor open={open} setOpen={setOpen} />
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ const DialogContent = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { withoutClose?: boolean }
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { withoutClose?: boolean }
|
||||||
>(({ className, children, withoutClose, ...props }, ref) => (
|
>(({ className, children, withoutClose, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
{/* <DialogOverlay /> */}
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -53,15 +53,17 @@ interface SheetContentProps
|
|||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
const SheetContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
SheetContentProps
|
SheetContentProps & { hideClose?: boolean }
|
||||||
>(({ side = 'right', className, children, ...props }, ref) => (
|
>(({ side = 'right', className, children, hideClose = false, ...props }, ref) => (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
{!hideClose && (
|
||||||
<X className="h-4 w-4" />
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
<span className="sr-only">Close</span>
|
<X className="h-4 w-4" />
|
||||||
</SheetPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
|
|||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -105,6 +105,8 @@ export default {
|
|||||||
'A special note for picture-first clients like Olas',
|
'A special note for picture-first clients like Olas',
|
||||||
'Picture note requires images': 'Picture note requires images',
|
'Picture note requires images': 'Picture note requires images',
|
||||||
Relays: 'Relays',
|
Relays: 'Relays',
|
||||||
image: 'image'
|
image: 'image',
|
||||||
|
'Normal Post': 'Normal Post',
|
||||||
|
'Picture Post': 'Picture Post'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export default {
|
|||||||
'一种可以在图片优先客户端 (如 Olas) 中显示的特殊笔记',
|
'一种可以在图片优先客户端 (如 Olas) 中显示的特殊笔记',
|
||||||
'Picture note requires images': '图片笔记需要有图片',
|
'Picture note requires images': '图片笔记需要有图片',
|
||||||
Relays: '服务器',
|
Relays: '服务器',
|
||||||
image: '图片'
|
image: '图片',
|
||||||
|
Normal: '普通',
|
||||||
|
'Normal Post': '普通笔记',
|
||||||
|
'Picture Post': '图片笔记'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user