6 changed files with 538 additions and 15 deletions
@ -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