'use client' import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react' import { useRouter } from 'next/navigation' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { toast } from 'sonner' import { ChevronLeft, ChevronRight, Code, Download, Eye, FileText, GripVertical, Heading1, Heading2, Heading3, Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, Plus, Quote, Search, Trash2, Type, X, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { cn } from '@/lib/utils' import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave' import { PAGES_CHANGED, nextUntitledTitle, notifyActivePage, notifyPagesChanged, type PageSummary } from '@/lib/pages' import { reorderPages } from '@/lib/page-order' import { fetchLinkPreview } from '@/lib/link-preview' import { LinkPreviewCard } from '@/components/link-preview-card' import type { Element, ElementContent } from 'hast' export { PAGES_CHANGED } // Notion-style slash menu: typing "/" at the start of a line offers blocks // that insert their Markdown equivalent. The textarea stays the source of // truth, so autosave and conflict handling are untouched. const SLASH_BLOCKS = [ { key: 'text', label: 'Text', desc: 'Plain paragraph', icon: Type, prefix: '' }, { key: 'h1', label: 'Heading 1', desc: 'Large section heading', icon: Heading1, prefix: '# ' }, { key: 'h2', label: 'Heading 2', desc: 'Medium section heading', icon: Heading2, prefix: '## ' }, { key: 'h3', label: 'Heading 3', desc: 'Small section heading', icon: Heading3, prefix: '### ' }, { key: 'todo', label: 'To-do list', desc: 'Track tasks with a checkbox', icon: ListTodo, prefix: '- [ ] ' }, { key: 'bullet', label: 'Bulleted list', desc: 'Simple bulleted list', icon: List, prefix: '- ' }, { key: 'num', label: 'Numbered list', desc: 'List with numbering', icon: ListOrdered, prefix: '1. ' }, { key: 'quote', label: 'Quote', desc: 'Capture a quote', icon: Quote, prefix: '> ' }, { key: 'div', label: 'Divider', desc: 'Visual section break', icon: Minus, prefix: '---\n' }, { key: 'code', label: 'Code', desc: 'Code snippet block', icon: Code, prefix: '```\n' }, ] as const type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null } type PageDetail = PageSummary & { content: string; version: number; bookmarks: { bookmark: LinkedBookmark }[] } const notify = notifyPagesChanged const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } } const textOf = (nodes: ElementContent[]): string => nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('') // A paragraph whose only content is a single external link — a bare URL // autolinked by GFM or a lone [label](url) — renders as a Notion-style // preview card instead of a plain

. Links woven into sentences stay // inline text; the Markdown source remains the source of truth either way. function standaloneLink(node: Element | undefined): { href: string; label: string } | null { if (!node) return null const kids = node.children.filter((c) => !(c.type === 'text' && c.value.trim() === '')) if (kids.length !== 1) return null const a = kids[0] if (a.type !== 'element' || a.tagName !== 'a') return null const href = a.properties?.href if (typeof href !== 'string' || !/^https?:\/\//i.test(href)) return null return { href, label: textOf(a.children) } } export function PagesClient({ initialId }: { initialId: string | null }) { const router = useRouter() const [pages, setPages] = useState([]) const [activeId, setActiveId] = useState(initialId) const [page, setPage] = useState(null) const [filter, setFilter] = useState('') const [mode, setMode] = useState<'edit' | 'preview'>('preview') const [title, setTitle] = useState('') const [content, setContent] = useState('') const [saveStates, setSaveStates] = useState>({}) const [linkQuery, setLinkQuery] = useState('') const [results, setResults] = useState([]) const [linkOpen, setLinkOpen] = useState(false) const [slash, setSlash] = useState<{ query: string; start: number } | null>(null) const [slashIndex, setSlashIndex] = useState(0) const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null) const textareaRef = useRef(null) const pendingCaret = useRef(null) const suppressTabClick = useRef(false) const draggedTabRef = useRef(null) const activePage = useRef(initialId) const loadSequence = useRef(0) const selectionSequence = useRef(0) // Optimistic concurrency (review DATA-01): PageAutosave tracks the server // revision per page and sends it with content saves, so a newer save from // another tab or device answers 409 instead of being silently overwritten. const persistContent = useCallback(async (id: string, content: string, version: number | undefined) => { const body = JSON.stringify(typeof version === 'number' ? { content, version } : { content }) let res: Response try { res = await fetch(`/api/pages/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body, keepalive: new TextEncoder().encode(body).length < 60000, }) } catch (error) { toast.error('Could not save page. Your draft is kept in this tab; retry saving.') throw error } if (res.status === 409) { // Another session saved first. Review DATA-04: the queue enters a // paused conflict state instead of adopting the remote version, so // nothing overwrites the newer content until the user explicitly // chooses to overwrite, discard, or copy the draft. let serverVersion: number | undefined try { const latest = await fetch(`/api/pages/${id}`, { cache: 'no-store' }).then(r => (r.ok ? r.json() : null)) if (latest && typeof latest.version === 'number') serverVersion = latest.version } catch { /* keep the stale base; the conflict state pauses autosave either way */ } toast.error('This page was saved in another tab or device. Autosave is paused so nothing is overwritten until you resolve the conflict.') throw new PageConflictError(serverVersion) } if (!res.ok) { toast.error('Could not save page. Your draft is kept in this tab; retry saving.') throw new Error('Save failed') } const saved = await res.json().catch(() => null) return saved && typeof saved.version === 'number' ? saved.version : undefined }, []) const [autosave] = useState(() => new PageAutosave(persistContent, (id, state) => setSaveStates(previous => ({ ...previous, [id]: state })))) const saveState = activeId ? saveStates[activeId] ?? 'saved' : 'saved' useEffect(() => { const beforeUnload = (event: BeforeUnloadEvent) => { if (autosave.hasUnsaved()) { event.preventDefault(); event.returnValue = '' } } const flush = () => { void autosave.flushAll() } window.addEventListener('beforeunload', beforeUnload) window.addEventListener('pagehide', flush) return () => { window.removeEventListener('beforeunload', beforeUnload) window.removeEventListener('pagehide', flush) flush() } }, [autosave]) const loadPages = useCallback(async () => { const res = await fetch('/api/pages') if (!res.ok) return [] as PageSummary[] const data: PageSummary[] = await res.json() setPages(data) return data }, []) const loadPage = useCallback(async (id: string) => { const sequence = ++loadSequence.current const res = await fetch(`/api/pages/${id}`) if (sequence !== loadSequence.current || activePage.current !== id) return if (!res.ok) { setPage(null); notifyActivePage(null); return } const data: PageDetail = await res.json() if (sequence !== loadSequence.current || activePage.current !== id) return if (typeof data.version === 'number') autosave.setVersion(data.id, data.version) // Review DATA-03: recovery compares the draft's original base version // with the freshly fetched server version; a mismatch pauses autosave in // the conflict state instead of silently rebasing onto the newer save. const draft = autosave.restore(id, data.version) setPage(data) setTitle(data.title) setContent(draft ?? data.content) notifyActivePage(data.title) if (draft !== undefined || !data.content) setMode('edit') }, [autosave]) useEffect(() => { const timer = setTimeout(() => loadPages().then((data) => { if (!activePage.current && data.length) { activePage.current = data[0].id; setActiveId(data[0].id) } }).catch((e) => console.error('Failed to load pages', e)), 0) return () => clearTimeout(timer) }, [loadPages]) useEffect(() => { const timer = setTimeout(() => { if (activeId) loadPage(activeId).catch((e) => console.error('Failed to load page', e)) else setPage(null) }, 0) return () => clearTimeout(timer) }, [activeId, loadPage]) const select = async (id: string) => { const sequence = ++selectionSequence.current if (activePage.current && !await autosave.flush(activePage.current)) return if (sequence !== selectionSequence.current) return activePage.current = id setActiveId(id) router.replace(`/pages?id=${id}`) } // The sidebar can create or open pages while this workspace stays mounted. useEffect(() => { if (!initialId || initialId === activePage.current) return void loadPages() void select(initialId) }, [initialId]) // Sidebar mutations (rename, pin, delete) announce themselves; keep the // page list in step and drop the detail view when the open page is gone. useEffect(() => { const reload = () => { void loadPages().then((data) => { if (activePage.current && !data.some((p) => p.id === activePage.current)) { activePage.current = null setActiveId(null) setPage(null) notifyActivePage(null) router.replace('/pages') } }) } window.addEventListener(PAGES_CHANGED, reload) return () => window.removeEventListener(PAGES_CHANGED, reload) }, [loadPages, router]) // Leaving the pages workspace clears the breadcrumb title. useEffect(() => () => { notifyActivePage(null) }, []) const patch = async (body: Record) => { if (!activeId || page?.id !== activeId) return null const res = await fetch(`/api/pages/${page.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) if (!res.ok) { toast.error('Could not save page'); return null } // Review CR-17: metadata responses (rename/pin/link changes) carry the // CURRENT content revision but never require or acknowledge a content // base — adopting it here would silently rebase an in-flight stale draft // onto another editor's save and authorize overwriting it. Only content // saves (persistContent) and fresh page loads may move the version map. return res } const createPage = async () => { if (activePage.current && !await autosave.flush(activePage.current)) return const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: nextUntitledTitle(pages.map((p) => p.title)) }) }) if (!res.ok) { toast.error('Could not create page'); return } const p = await res.json() await loadPages() notify() setMode('edit') select(p.id) } const onContentChange = (v: string) => { if (!page || page.id !== activeId) return setContent(v) autosave.edit(page.id, v) } // Show the block menu only while the caret sits on a bare "/token" line. const updateSlash = (text: string, caret: number) => { const lineStart = text.lastIndexOf('\n', caret - 1) + 1 const line = text.slice(lineStart, caret) const m = /^\/([\w-]*)$/.exec(line) if (m) { setSlash({ query: m[1].toLowerCase(), start: lineStart }); setSlashIndex(0) } else setSlash(null) } const slashFiltered = SLASH_BLOCKS.filter((b) => !slash?.query || b.key.includes(slash.query) || b.label.toLowerCase().includes(slash.query)) const applyBlock = (block: (typeof SLASH_BLOCKS)[number]) => { const ta = textareaRef.current if (!slash || !ta) return const caret = ta.selectionStart const next = content.slice(0, slash.start) + block.prefix + content.slice(caret) pendingCaret.current = slash.start + block.prefix.length onContentChange(next) setSlash(null) } // Restore the caret after a programmatic content rewrite (slash insert, // "[] " shortcut). Runs once the new content has reached the textarea. useEffect(() => { if (pendingCaret.current === null || mode !== 'edit') return const ta = textareaRef.current if (!ta) return const c = pendingCaret.current pendingCaret.current = null ta.focus() ta.setSelectionRange(c, c) }, [content, mode]) const saveTitle = async () => { if (!page) return const t = title.trim() // Keep the generated "Untitled N" name until the user gives a real one. if (!t) { setTitle(page.title); return } if (t === page.title) return if (await patch({ title: t })) { setPage(current => current?.id === page.id ? { ...current, title: t } : current) notifyActivePage(t) await loadPages(); notify() } } const togglePin = async () => { if (!page) return if (await patch({ pinned: !page.pinned })) { setPage(current => current?.id === page.id ? { ...current, pinned: !page.pinned } : current) await loadPages(); notify() } } const move = async (dir: -1 | 1) => { if (!activeId) return const idx = pages.findIndex((p) => p.id === activeId) const target = pages[idx + dir] if (idx < 0 || !target) return const next = reorderPages(pages, activeId, target.id, dir > 0) if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return } if (next) await savePageOrder(next) } const savePageOrder = async (next: PageSummary[]) => { setPages(next) const res = await fetch('/api/pages', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: next.map((p) => p.id) }) }) if (!res.ok) { toast.error('Could not reorder pages'); await loadPages(); return } await loadPages() notify() } const dropTab = (target: PageSummary, after: boolean) => { const source = draggedTabRef.current draggedTabRef.current = null setTabDrop(null) if (!source) return const next = reorderPages(pages, source, target.id, after) if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return } if (next) void savePageOrder(next) } const download = async () => { if (!activeId) return // A paused conflict can never flush; exporting the server copy is still // useful, so only ordinary save failures block the download. if (autosave.conflictVersion(activeId) === null && !await autosave.flush(activeId)) return const a = document.createElement('a') a.href = `/api/pages/${activeId}/markdown` a.download = '' document.body.appendChild(a); a.click(); a.remove() } // Explicit conflict resolutions (review DATA-04). Typing is never consent; // each path is a deliberate click. const overwriteRemote = async () => { if (!activeId) return if (await autosave.resolveOverwrite(activeId)) toast.success('Your draft replaced the newer version') else toast.error('Could not save page. Your draft is kept in this tab; retry saving.') } const discardDraft = async () => { if (!activeId) return autosave.discard(activeId) await loadPage(activeId) } const copyDraft = async () => { const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: `${page?.title ?? 'Untitled'} (draft copy)`, content }), }) if (!res.ok) { toast.error('Could not create the copy'); return } toast.success('Draft copied to a new page') await loadPages(); notify() } const remove = async () => { if (!page || !confirm(`Delete page "${page.title}"? Bookmarks are kept.`)) return if (!await autosave.flush(page.id)) return const res = await fetch(`/api/pages/${page.id}`, { method: 'DELETE' }) if (!res.ok) { toast.error('Could not delete page'); return } autosave.forget(page.id) const data = await loadPages(); notify() if (data.length) select(data[0].id) else { activePage.current = null; setActiveId(null); router.replace('/pages') } } useEffect(() => { if (!linkOpen) return const q = linkQuery.trim() const t = setTimeout(async () => { try { const params = new URLSearchParams({ limit: '8' }) if (q) params.set('search', q) const res = await fetch(`/api/bookmarks?${params}`) if (res.ok) setResults((await res.json()).slice(0, 8)) } catch { /* transient; retried on next keystroke */ } }, 250) return () => clearTimeout(t) }, [linkQuery, linkOpen]) const addBookmark = async (bookmarkId: string) => { if (activeId && !await autosave.flush(activeId)) return if (await patch({ addBookmarkId: bookmarkId })) { setLinkOpen(false); setLinkQuery('') if (activeId) await loadPage(activeId) await loadPages() } } const addUrl = async () => { let url = linkQuery.trim() if (!url) return if (!/^https?:\/\//i.test(url)) url = `https://${url}` const res = await fetch('/api/bookmarks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }) if (!res.ok) { toast.error('Could not save URL'); return } const b = await res.json() await addBookmark(b.id) toast.success('Link added') } const removeBookmark = async (bookmarkId: string) => { if (await patch({ removeBookmarkId: bookmarkId })) { if (page) setPage({ ...page, bookmarks: page.bookmarks.filter((x) => x.bookmark.id !== bookmarkId) }) await loadPages() } } // Notion-style paste: a bare URL lands in the textarea untouched, and once // its preview resolves we offer to upgrade it to a titled Markdown link. // Nothing is rewritten without an explicit click, so undo stays intact. const offerTitleForPastedUrl = (url: string) => { void fetchLinkPreview(url).then((p) => { const raw = p?.ogTitle || p?.title if (!raw) return const title = raw.replace(/[\[\]\n]/g, ' ').trim() if (!title || title === url) return toast.info('Paste as titled link?', { description: title, duration: 10000, action: { label: 'Use title', onClick: () => { const ta = textareaRef.current if (!ta) return const idx = ta.value.indexOf(url) if (idx < 0) return const markdown = `[${title}](${url})` pendingCaret.current = idx + markdown.length onContentChange(ta.value.slice(0, idx) + markdown + ta.value.slice(idx + url.length)) }, }, }) }) } const onPaste = (e: ClipboardEvent) => { const text = e.clipboardData.getData('text/plain').trim() if (/^https?:\/\/\S+$/i.test(text)) offerTitleForPastedUrl(text) } const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase())) const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim()) return (

setFilter(e.target.value)} placeholder="Filter pages" className="h-8 pl-7 text-sm" />
{visible.map((p) => (
{ if (!draggedTabRef.current || draggedTabRef.current === p.id) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const rect = e.currentTarget.getBoundingClientRect() setTabDrop({ id: p.id, after: e.clientX > rect.left + rect.width / 2 }) }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setTabDrop(null) }} onDrop={(e) => { e.preventDefault() const rect = e.currentTarget.getBoundingClientRect() dropTab(p, e.clientX > rect.left + rect.width / 2) }} title={filter.trim() ? 'Clear the filter to reorder pages' : 'Drag the grip to reorder page'} className={cn( 'group/tab flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors', p.id === activeId ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground', tabDrop?.id === p.id && 'ring-1 ring-primary/50', )} > {!filter.trim() && ( { suppressTabClick.current = true draggedTabRef.current = p.id e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', p.id) }} onDragEnd={() => { draggedTabRef.current = null setTabDrop(null) setTimeout(() => { suppressTabClick.current = false }, 0) }} className="cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/tab:opacity-100" aria-label={`Drag page ${p.title}`} > )}
))}
{!page || page.id !== activeId ? (

{pages.length ? 'Select a page' : 'No pages yet'}

Pages are Markdown notes that collect links into a workspace. A bookmark can live on many pages.

) : (
setTitle(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()} className="min-w-0 flex-1 bg-transparent font-display text-3xl font-bold outline-none" aria-label="Page title" /> {saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : 'Saved'} {saveState === 'dirty' && }
{saveState === 'conflict' && (

This page was changed elsewhere

Your local draft is based on an older version — or its base version is unknown — so autosave is paused and nothing is overwritten until you choose. Your draft stays in the editor below.

)} {mode === 'edit' ? (