diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 09201de9..4088b800 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -238,6 +238,47 @@ function buildRelayUrl(relayUrl: string, currentPage: TPrimaryPageName | null): return `/relays/${encodedRelayUrl}` } +/** Path (+ query for spells) pushed when navigating primary pages — shareable URLs for faux spells. */ +function buildPrimaryPageUrl( + page: TPrimaryPageName, + props?: { spell?: string } | Record | null +): string { + if (page === 'home') return '/' + if (page === 'spells') { + const spell = + props && typeof (props as { spell?: unknown }).spell === 'string' + ? String((props as { spell: string }).spell).trim() + : '' + if (spell) return `/spells?spell=${encodeURIComponent(spell)}` + return '/spells' + } + return `/${page}` +} + +function spellPropsFromSearch(search: string): { spell: string } | undefined { + const spell = new URLSearchParams(search).get('spell')?.trim() + return spell ? { spell } : undefined +} + +/** Primary URL for drawer/overlay restore when we only have pathname + optional full URL for query. */ +function restoredPrimaryBrowserUrl(pathname: string, fullUrlForQuery: string): string { + const popSegments = pathname.split('/').filter(Boolean) + const popFirstSeg = popSegments[0] ?? '' + if (popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home')) { + return '/' + } + if (popSegments.length === 1 && popFirstSeg === 'spells') { + try { + const sp = new URL(fullUrlForQuery, window.location.origin).searchParams.get('spell')?.trim() + return buildPrimaryPageUrl('spells', sp ? { spell: sp } : undefined) + } catch { + return '/spells' + } + } + if (popSegments.length === 1) return `/${popFirstSeg}` + return pathname +} + // Helper function to extract noteId and context from URL function parseNoteUrl(url: string): { noteId: string; context?: string } { // Match patterns like /discussions/notes/{noteId} or /notes/{noteId} @@ -624,6 +665,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const navigationCounterRef = useRef(0) const savedFeedStateRef = useRef>(new Map()) const currentTabStateRef = useRef>(new Map()) // Track current tab state for each page + const savedPrimaryPagePropsRef = useRef(undefined) + const primaryPagePropsRef = useRef>(new Map()) const currentPageProps = useMemo((): object | undefined => { const entry = primaryPages.find((p) => p.name === currentPrimaryPage) @@ -633,6 +676,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => { if (view && !primaryNoteView) { // Saving current primary page before showing overlay + savedPrimaryPagePropsRef.current = primaryPages.find((p) => p.name === currentPrimaryPage)?.props as + | object + | undefined setSavedPrimaryPage(currentPrimaryPage) // Get current tab state from ref (updated by components via events) @@ -662,7 +708,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // If clearing the view, restore to the saved primary page if (!view && savedPrimaryPage) { - const newUrl = savedPrimaryPage === 'home' ? '/' : `/${savedPrimaryPage}` + const newUrl = buildPrimaryPageUrl( + savedPrimaryPage, + savedPrimaryPagePropsRef.current as { spell?: string } | undefined + ) window.history.replaceState(null, '', newUrl) const savedFeedState = savedFeedStateRef.current.get(savedPrimaryPage) @@ -826,6 +875,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }) } else if (pageName === 'discussions') { navigatePrimaryPage('spells', { spell: 'discussions' }) + } else if (pageName === 'spells') { + const spellProps = spellPropsFromSearch(window.location.search) + navigatePrimaryPage('spells', spellProps) } else if (pageName in primaryMap) { navigatePrimaryPage(pageName as TPrimaryPageName) } @@ -898,6 +950,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }) return } + if (pageName === 'spells') { + const spellProps = spellPropsFromSearch(window.location.search) + navigatePrimaryPage('spells', spellProps) + return + } if (pageName && pageName in getPrimaryPageMap()) { // For relay page, check if there's a URL prop if (pageName === 'relay') { @@ -934,6 +991,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const noteUrlMatch = urlToCheck.match(/\/(discussions|search|profile|home|feed|spells|explore)\/notes\/(.+)$/) || urlToCheck.match(/\/notes\/(.+)$/) const noteIdToShow = noteUrlMatch ? noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] : null + + // Keep spells faux spell in sync with ?spell= on browser back/forward + if (!noteIdToShow) { + const syncSegs = window.location.pathname.split('/').filter(Boolean) + if (syncSegs.length === 1 && syncSegs[0] === 'spells') { + const spellProps = spellPropsFromSearch(window.location.search) + setCurrentPrimaryPage('spells') + setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'spells', props: spellProps })) + } + } // If not a note URL and drawer is open - close the drawer immediately // Only in single-pane mode or mobile @@ -942,7 +1009,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setTimeout(() => { setDrawerNoteId(null) // Restore URL to current primary page - const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}` + const pageUrl = buildPrimaryPageUrl( + currentPrimaryPage, + primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined + ) window.history.replaceState(null, '', pageUrl) }, 350) } @@ -993,13 +1063,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // On mobile or single-pane: if drawer is open, close it if (drawerOpen && (isSmallScreen || panelMode === 'single')) { setDrawerOpen(false) + const historyUrl = state!.url setTimeout(() => { setDrawerNoteId(null) - // Ensure URL matches the primary page - const pageUrl = - popSegments.length === 0 || (popSegments.length === 1 && popFirstSeg === 'home') - ? '/' - : `/${popFirstSeg}` + // Ensure URL matches the primary page (preserve /spells?spell=) + const pageUrl = restoredPrimaryBrowserUrl(pathname, historyUrl) window.history.replaceState(null, '', pageUrl) }, 350) } @@ -1151,9 +1219,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }) setCurrentPrimaryPage(page) - // Update URL for primary pages - use dedicated paths - // Home can be either / or /home, but we'll use / for home - const newUrl = page === 'home' ? '/' : `/${page}` + // Update URL for primary pages (spells uses ?spell= for faux feeds) + const newUrl = buildPrimaryPageUrl(page, props) window.history.pushState(null, '', newUrl) // NEVER scroll to top - feed should maintain scroll position at all times @@ -1442,7 +1509,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Use 350ms to ensure animation is fully done (animation is 300ms) if (!open) { // Restore URL to current primary page - const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}` + const pageUrl = buildPrimaryPageUrl( + currentPrimaryPage, + primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined + ) window.history.replaceState(null, '', pageUrl) setTimeout(() => setDrawerNoteId(null), 350) } @@ -1560,7 +1630,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // Use 350ms to ensure animation is fully done (animation is 300ms) if (!open) { // Restore URL to current primary page - const pageUrl = currentPrimaryPage === 'home' ? '/' : `/${currentPrimaryPage}` + const pageUrl = buildPrimaryPageUrl( + currentPrimaryPage, + primaryPagePropsRef.current.get(currentPrimaryPage) as { spell?: string } | undefined + ) window.history.replaceState(null, '', pageUrl) setTimeout(() => setDrawerNoteId(null), 350) } diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx new file mode 100644 index 00000000..8a6dcd74 --- /dev/null +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -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) => ( + {node} + ) + if (k === ExtendedKind.POLL) { + return wrap() + } + if (k === kinds.Highlights) { + return wrap() + } + if ( + k === kinds.ShortTextNote || + k === ExtendedKind.COMMENT || + k === ExtendedKind.VOICE_COMMENT + ) { + return wrap() + } + if (k === kinds.LongFormArticle) { + return wrap() + } + if (k === ExtendedKind.WIKI_ARTICLE) { + return wrap() + } + if (k === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { + return wrap() + } + if (k === ExtendedKind.PUBLICATION_CONTENT) { + return wrap() + } + return wrap() +} + +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([['', '']]) + 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 ( + + + + {title} + + {t('Edit content and tags, then publish a new signed event.')} + + + +
+ + + {t('Edit')} + {t('Preview')} + {t('Json')} + + + + +
+
+ + +
+
+ +