feat: picture notes editor

This commit is contained in:
codytseng
2025-01-12 17:18:45 +08:00
parent 2aba89419e
commit 5bf220fa5b
14 changed files with 467 additions and 119 deletions

30
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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,6 +82,8 @@ 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}
{index >= 0 &&
createPortal(
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Lightbox <Lightbox
index={index} index={index}
@@ -93,9 +96,13 @@ export default function ImageGallery({
closeOnPullUp: true, closeOnPullUp: true,
closeOnPullDown: true closeOnPullDown: true
}} }}
styles={{ toolbar: { paddingTop: '2.25rem' } }} styles={{
toolbar: { paddingTop: '2.25rem' }
}}
/> />
</div> </div>,
document.body
)}
{isNsfw && <NsfwOverlay className="rounded-lg" />} {isNsfw && <NsfwOverlay className="rounded-lg" />}
</div> </div>
) )

View File

@@ -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" />

View File

@@ -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,13 +57,8 @@ 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 })
: parentEvent && parentEvent.kind !== kinds.ShortTextNote
? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag }) ? await createCommentDraftEvent(content, parentEvent, pictureInfos, { addClientTag })
: await createShortTextNoteDraftEvent(content, pictureInfos, { : await createShortTextNoteDraftEvent(content, pictureInfos, {
parentEvent, parentEvent,
@@ -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">

View 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
)}
</>
)
}

View File

@@ -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,9 +65,13 @@ 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 ( return (
<> <>
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}> <Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
@@ -79,4 +86,26 @@ export default function Uploader({
/> />
</> </>
) )
}
return (
<>
<div
className={cn(
'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
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
accept="image/*"
/>
</>
)
} }

View File

@@ -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()
if (isSmallScreen) { const content = useMemo(() => {
return ( return parentEvent ? (
<Drawer open={open} onOpenChange={setOpen}> <NormalPostContent
<DrawerContent className="h-full">
<DrawerHeader>
<DrawerTitle className="text-start">
<Title parentEvent={parentEvent} />
</DrawerTitle>
<DrawerDescription className="hidden" />
</DrawerHeader>
<div className="overflow-auto py-2 px-4">
<PostContent
defaultContent={defaultContent} defaultContent={defaultContent}
parentEvent={parentEvent} parentEvent={parentEvent}
close={() => setOpen(false)} 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) {
return (
<Sheet open={open} onOpenChange={setOpen} modal={false}>
<SheetContent className="h-full w-full p-0 border-none" side="bottom" hideClose>
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<SheetHeader>
<SheetTitle className="text-start">
<Title parentEvent={parentEvent} />
</SheetTitle>
<SheetDescription className="hidden" />
</SheetHeader>
{content}
</div> </div>
</DrawerContent> </ScrollArea>
</Drawer> </SheetContent>
</Sheet>
) )
} }
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>

View File

@@ -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>
) )
} }

View File

@@ -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(

View File

@@ -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}>
{!hideClose && (
<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"> <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">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)}
{children} {children}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>

View 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 }

View File

@@ -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'
} }
} }

View File

@@ -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': '图片笔记'
} }
} }