6 changed files with 538 additions and 15 deletions
@ -0,0 +1,370 @@
@@ -0,0 +1,370 @@
|
||||
import { Card } from '@/components/ui/card' |
||||
import { |
||||
Dialog, |
||||
DialogContent, |
||||
DialogDescription, |
||||
DialogFooter, |
||||
DialogHeader, |
||||
DialogTitle |
||||
} from '@/components/ui/dialog' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
||||
import { ScrollArea } from '@/components/ui/scroll-area' |
||||
import Content from '@/components/Content' |
||||
import ContentPreview from '@/components/ContentPreview' |
||||
import Highlight from '@/components/Note/Highlight' |
||||
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' |
||||
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { createFakeEvent } from '@/lib/event' |
||||
import logger from '@/lib/logger' |
||||
import { |
||||
showPublishingError, |
||||
showPublishingFeedback, |
||||
showSimplePublishSuccess |
||||
} from '@/lib/publishing-feedback' |
||||
import { cn } from '@/lib/utils' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import dayjs from 'dayjs' |
||||
import { Plus, Trash2 } from 'lucide-react' |
||||
import { Event, kinds } from 'nostr-tools' |
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
function normalizeTagRow(row: string[]): string[] | null { |
||||
const trimmed = row.map((c) => c.trim()) |
||||
if (!trimmed[0]) return null |
||||
let end = trimmed.length |
||||
while (end > 1 && trimmed[end - 1] === '') end-- |
||||
return trimmed.slice(0, end) |
||||
} |
||||
|
||||
function tagsFromRows(rows: string[][]): string[][] { |
||||
const out: string[][] = [] |
||||
for (const row of rows) { |
||||
const n = normalizeTagRow(row) |
||||
if (n) out.push(n) |
||||
} |
||||
return out |
||||
} |
||||
|
||||
function StaticEventPreview({ event, className }: { event: Event; className?: string }) { |
||||
const k = event.kind |
||||
const wrap = (node: ReactNode) => ( |
||||
<Card className={cn('p-3 select-text', className)}>{node}</Card> |
||||
) |
||||
if (k === ExtendedKind.POLL) { |
||||
return wrap(<ContentPreview event={event} />) |
||||
} |
||||
if (k === kinds.Highlights) { |
||||
return wrap(<Highlight event={event} />) |
||||
} |
||||
if ( |
||||
k === kinds.ShortTextNote || |
||||
k === ExtendedKind.COMMENT || |
||||
k === ExtendedKind.VOICE_COMMENT |
||||
) { |
||||
return wrap(<MarkdownArticle event={event} hideMetadata />) |
||||
} |
||||
if (k === kinds.LongFormArticle) { |
||||
return wrap(<MarkdownArticle event={event} hideMetadata />) |
||||
} |
||||
if (k === ExtendedKind.WIKI_ARTICLE) { |
||||
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />) |
||||
} |
||||
if (k === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { |
||||
return wrap(<MarkdownArticle event={event} hideMetadata />) |
||||
} |
||||
if (k === ExtendedKind.PUBLICATION_CONTENT) { |
||||
return wrap(<AsciidocArticle event={event} hideImagesAndInfo={false} />) |
||||
} |
||||
return wrap(<Content event={event} className="h-full" mustLoadMedia />) |
||||
} |
||||
|
||||
export type TEditOrCloneMode = 'edit' | 'clone' |
||||
|
||||
export default function EditOrCloneEventDialog({ |
||||
open, |
||||
onOpenChange, |
||||
sourceEvent, |
||||
mode |
||||
}: { |
||||
open: boolean |
||||
onOpenChange: (open: boolean) => void |
||||
sourceEvent: Event |
||||
mode: TEditOrCloneMode |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { pubkey, publish, checkLogin } = useNostr() |
||||
const [content, setContent] = useState(sourceEvent.content) |
||||
const [tagRows, setTagRows] = useState<string[][]>([['', '']]) |
||||
const [activeTab, setActiveTab] = useState('edit') |
||||
const [publishing, setPublishing] = useState(false) |
||||
const prevOpenRef = useRef(false) |
||||
|
||||
const kind = sourceEvent.kind |
||||
|
||||
useEffect(() => { |
||||
if (open && !prevOpenRef.current) { |
||||
setContent(sourceEvent.content) |
||||
setTagRows( |
||||
sourceEvent.tags?.length |
||||
? sourceEvent.tags.map((row) => [...row]) |
||||
: [['', '']] |
||||
) |
||||
setActiveTab('edit') |
||||
} |
||||
prevOpenRef.current = open |
||||
}, [open, sourceEvent]) |
||||
|
||||
const normalizedTags = useMemo(() => tagsFromRows(tagRows), [tagRows]) |
||||
|
||||
const previewEvent = useMemo(() => { |
||||
const now = Math.floor(Date.now() / 1000) |
||||
return createFakeEvent({ |
||||
kind, |
||||
content, |
||||
tags: normalizedTags, |
||||
pubkey: pubkey ?? '', |
||||
created_at: now |
||||
}) |
||||
}, [kind, content, normalizedTags, pubkey]) |
||||
|
||||
const buildDraftJson = useCallback(() => { |
||||
const draft = { |
||||
pubkey: pubkey ?? t('Log in to publish'), |
||||
kind, |
||||
content, |
||||
tags: normalizedTags, |
||||
created_at: t('Set when you publish'), |
||||
_note: t('id and sig are assigned when you publish') |
||||
} |
||||
return JSON.stringify(draft, null, 2) |
||||
}, [pubkey, kind, content, normalizedTags, t]) |
||||
|
||||
const draftJson = activeTab === 'json' ? buildDraftJson() : '' |
||||
|
||||
const updateRow = (i: number, j: number, value: string) => { |
||||
setTagRows((rows) => { |
||||
const next = rows.map((r) => [...r]) |
||||
if (!next[i]) return rows |
||||
next[i][j] = value |
||||
return next |
||||
}) |
||||
} |
||||
|
||||
const addRow = () => setTagRows((rows) => [...rows, ['', '']]) |
||||
|
||||
const removeRow = (i: number) => { |
||||
setTagRows((rows) => (rows.length <= 1 ? [['', '']] : rows.filter((_, idx) => idx !== i))) |
||||
} |
||||
|
||||
const addCell = (i: number) => { |
||||
setTagRows((rows) => { |
||||
const next = rows.map((r) => [...r]) |
||||
next[i] = [...next[i], ''] |
||||
return next |
||||
}) |
||||
} |
||||
|
||||
const removeCell = (i: number, j: number) => { |
||||
setTagRows((rows) => { |
||||
const next = rows.map((r) => [...r]) |
||||
if (next[i].length <= 1) return rows |
||||
next[i] = next[i].filter((_, idx) => idx !== j) |
||||
return next |
||||
}) |
||||
} |
||||
|
||||
const handlePublish = async () => { |
||||
await checkLogin(async () => { |
||||
if (!pubkey) return |
||||
setPublishing(true) |
||||
try { |
||||
const draft = { |
||||
kind, |
||||
content, |
||||
tags: normalizedTags, |
||||
created_at: dayjs().unix() |
||||
} |
||||
const newEvent = await publish(draft) |
||||
if ((newEvent as any)?.relayStatuses) { |
||||
const rs = (newEvent as any).relayStatuses |
||||
showPublishingFeedback( |
||||
{ |
||||
success: true, |
||||
relayStatuses: rs, |
||||
successCount: rs.filter((s: any) => s.success).length, |
||||
totalCount: rs.length |
||||
}, |
||||
{ message: t('Post published'), duration: 6000 } |
||||
) |
||||
} else { |
||||
showSimplePublishSuccess(t('Post published')) |
||||
} |
||||
onOpenChange(false) |
||||
} catch (e) { |
||||
if (e instanceof AggregateError && (e as any).relayStatuses) { |
||||
const relayStatuses = (e as any).relayStatuses |
||||
const successCount = relayStatuses.filter((s: any) => s.success).length |
||||
const totalCount = relayStatuses.length |
||||
showPublishingFeedback( |
||||
{ |
||||
success: successCount > 0, |
||||
relayStatuses, |
||||
successCount, |
||||
totalCount |
||||
}, |
||||
{ |
||||
message: |
||||
successCount > 0 ? t('Published to some relays only') : t('Failed to post'), |
||||
duration: 6000 |
||||
} |
||||
) |
||||
if (successCount > 0) onOpenChange(false) |
||||
} else { |
||||
logger.error('Edit/clone publish failed', { error: e }) |
||||
showPublishingError(e instanceof Error ? e : String(e)) |
||||
} |
||||
} finally { |
||||
setPublishing(false) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const title = |
||||
mode === 'edit' ? t('Edit this event') : t('Clone or fork this event') |
||||
|
||||
return ( |
||||
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
<DialogContent className="max-h-[90vh] w-[95vw] max-w-3xl flex flex-col gap-0 p-0 overflow-hidden"> |
||||
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-14"> |
||||
<DialogTitle>{title}</DialogTitle> |
||||
<DialogDescription className="sr-only"> |
||||
{t('Edit content and tags, then publish a new signed event.')} |
||||
</DialogDescription> |
||||
</DialogHeader> |
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col px-6"> |
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 min-h-0 gap-2"> |
||||
<TabsList className="w-auto justify-start shrink-0"> |
||||
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger> |
||||
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger> |
||||
<TabsTrigger value="json">{t('Json')}</TabsTrigger> |
||||
</TabsList> |
||||
|
||||
<TabsContent value="edit" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"> |
||||
<ScrollArea className="h-[min(50vh,420px)] pr-3"> |
||||
<div className="space-y-4 pb-2"> |
||||
<div className="space-y-1"> |
||||
<label className="text-sm font-medium">{t('Event kind')}</label> |
||||
<Input |
||||
type="number" |
||||
value={kind} |
||||
disabled |
||||
readOnly |
||||
className="font-mono text-sm" |
||||
aria-readonly |
||||
/> |
||||
</div> |
||||
<div className="space-y-1"> |
||||
<label className="text-sm font-medium">{t('Note content')}</label> |
||||
<Textarea |
||||
value={content} |
||||
onChange={(e) => setContent(e.target.value)} |
||||
rows={10} |
||||
className="font-mono text-sm min-h-[160px]" |
||||
/> |
||||
</div> |
||||
<div className="space-y-2"> |
||||
<div className="text-sm font-medium">{t('Tags')}</div> |
||||
<div className="space-y-2"> |
||||
{tagRows.map((row, i) => ( |
||||
<div |
||||
key={i} |
||||
className="flex flex-wrap items-start gap-1 border rounded-md p-2 bg-muted/30" |
||||
> |
||||
{row.map((cell, j) => ( |
||||
<div key={j} className="flex items-center gap-0.5 shrink-0"> |
||||
<Input |
||||
value={cell} |
||||
onChange={(e) => updateRow(i, j, e.target.value)} |
||||
placeholder={j === 0 ? t('Tag name') : t('Value')} |
||||
className="h-8 w-[7rem] sm:w-32 font-mono text-xs" |
||||
/> |
||||
{row.length > 1 && ( |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="h-8 w-8 shrink-0" |
||||
onClick={() => removeCell(i, j)} |
||||
aria-label={t('Remove value')} |
||||
> |
||||
<Trash2 className="h-3.5 w-3.5" /> |
||||
</Button> |
||||
)} |
||||
</div> |
||||
))} |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
size="sm" |
||||
className="h-8" |
||||
onClick={() => addCell(i)} |
||||
> |
||||
<Plus className="h-3.5 w-3.5 mr-1" /> |
||||
{t('Add field')} |
||||
</Button> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="sm" |
||||
className="h-8 ml-auto" |
||||
onClick={() => removeRow(i)} |
||||
aria-label={t('Remove tag')} |
||||
> |
||||
<Trash2 className="h-4 w-4" /> |
||||
</Button> |
||||
</div> |
||||
))} |
||||
</div> |
||||
<Button type="button" variant="secondary" size="sm" onClick={addRow}> |
||||
<Plus className="h-4 w-4 mr-1" /> |
||||
{t('Add tag')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</ScrollArea> |
||||
</TabsContent> |
||||
|
||||
<TabsContent value="preview" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"> |
||||
<ScrollArea className="h-[min(50vh,420px)] pr-3"> |
||||
<StaticEventPreview event={previewEvent} /> |
||||
</ScrollArea> |
||||
</TabsContent> |
||||
|
||||
<TabsContent value="json" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden"> |
||||
<ScrollArea className="h-[min(50vh,420px)] pr-3"> |
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words border rounded-md p-3 bg-muted/40 select-text"> |
||||
{draftJson} |
||||
</pre> |
||||
</ScrollArea> |
||||
</TabsContent> |
||||
</Tabs> |
||||
</div> |
||||
|
||||
<DialogFooter className="shrink-0 px-6 py-4 border-t gap-2 sm:gap-0"> |
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button type="button" onClick={handlePublish} disabled={publishing || !pubkey}> |
||||
{publishing ? t('Loading...') : t('Publish')} |
||||
</Button> |
||||
</DialogFooter> |
||||
</DialogContent> |
||||
</Dialog> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue