You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
401 lines
14 KiB
401 lines
14 KiB
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 ClientTag from '@/components/ClientTag' |
|
import { ExtendedKind } from '@/constants' |
|
import { applyImwaldAttributionTags, stripImwaldAttributionTags } from '@/lib/draft-event' |
|
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 storage from '@/services/local-storage.service' |
|
import type { TDraftEvent } from '@/types' |
|
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) |
|
const base: TDraftEvent = { |
|
kind, |
|
content, |
|
tags: normalizedTags, |
|
created_at: now |
|
} |
|
const withAttribution = applyImwaldAttributionTags(base, { |
|
addClientTag: storage.getAddClientTag() |
|
}) |
|
return createFakeEvent({ |
|
kind, |
|
content, |
|
tags: withAttribution.tags, |
|
pubkey: pubkey ?? '', |
|
created_at: now |
|
}) |
|
}, [kind, content, normalizedTags, pubkey]) |
|
|
|
const buildDraftJson = useCallback(() => { |
|
const base: TDraftEvent = { |
|
kind, |
|
content, |
|
tags: normalizedTags, |
|
created_at: dayjs().unix() |
|
} |
|
const withAttribution = applyImwaldAttributionTags(base, { |
|
addClientTag: storage.getAddClientTag() |
|
}) |
|
const draft = { |
|
pubkey: pubkey ?? t('Log in to publish'), |
|
kind: withAttribution.kind, |
|
content: withAttribution.content, |
|
tags: withAttribution.tags, |
|
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, { |
|
addClientTag: storage.getAddClientTag() |
|
}) |
|
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"> |
|
<div className="space-y-1.5"> |
|
{storage.getAddClientTag() ? ( |
|
<div className="flex min-h-[1.125rem] items-center px-0.5"> |
|
<ClientTag event={previewEvent} /> |
|
</div> |
|
) : null} |
|
<StaticEventPreview event={previewEvent} /> |
|
</div> |
|
</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> |
|
) |
|
}
|
|
|