Files
brain/web/app/(app)/pages/client.tsx
T
Andrew S Erwin 4e421f6ea2
release gate / check (push) Failing after 6m43s
Address code review findings across API, web and deployment
High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
2026-10-20 00:13:30 -05:00

1045 lines
54 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { toast } from 'sonner'
import {
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
Heading1, Heading2, Heading3, History, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil,
Pin, PinOff, Plus, Quote, RotateCcw, 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
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, collectionEmbedMarkdown, descendantPageIds, internalPageId, nextUntitledTitle,
notifyActivePage, notifyPagesChanged, pageLinkMarkdown, parsePageEmbed, 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 PageBacklink = { id: string; title: string; updatedAt: string }
type EmbeddedProperty = {
id: string; name: string
type: 'TEXT' | 'NUMBER' | 'SELECT' | 'MULTI_SELECT' | 'DATE' | 'CHECKBOX' | 'URL'
position: number; config: { options?: { id: string; label: string; color?: string }[]; mode?: 'date' | 'datetime' }
}
type EmbeddedView = { id: string; name: string; type: string; position: number }
type PageEmbed = {
collectionId: string; viewId: string | null; position: number
collection: { id: string; name: string; icon: string | null; description: string | null; properties: EmbeddedProperty[]; views: EmbeddedView[] }
view: EmbeddedView | null
}
type EmbeddedItem = { id: string; propertyValues: Record<string, unknown>; bookmark: LinkedBookmark }
type PageDetail = PageSummary & {
content: string; version: number
bookmarks: { bookmark: LinkedBookmark }[]
backlinks: PageBacklink[]
links: { id: string; title: string }[]
embeds: PageEmbed[]
}
type PageRevisionSummary = { id: string; version: number; title: string; createdAt: string; length: number }
type PageRevisionDetail = PageRevisionSummary & { content: string; icon: string | null }
const notify = notifyPagesChanged
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
// Read-only live collection view embedded in a page (phase 3). It fetches the
// same authorized item endpoint the collection workspace uses, so a live
// embed never exposes a bookmark the viewer cannot already see and the
// server's filters/sorts still apply. It is a display, never a publication.
function PageEmbedCard({ embed }: { embed: PageEmbed }) {
const viewId = embed.viewId ?? embed.collection.views[0]?.id ?? null
const embedKey = `${embed.collectionId}|${viewId ?? ''}`
// The fetched result carries its embed key so switching collections never
// shows another embed's items for a frame; the effect only writes state
// asynchronously (no cascading synchronous setState).
const [loaded, setLoaded] = useState<{ key: string; items: EmbeddedItem[] | null; failed: boolean }>({ key: '', items: null, failed: false })
useEffect(() => {
let cancelled = false
const params = new URLSearchParams({ limit: '50' })
if (viewId) params.set('viewId', viewId)
fetch(`/api/collections/${embed.collectionId}/items?${params}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('load failed'))))
.then((data: EmbeddedItem[]) => { if (!cancelled) setLoaded({ key: embedKey, items: data, failed: false }) })
.catch(() => { if (!cancelled) setLoaded({ key: embedKey, items: null, failed: true }) })
return () => { cancelled = true }
}, [embed.collectionId, embedKey, viewId])
const current = loaded.key === embedKey ? loaded : { key: embedKey, items: null, failed: false }
const items = current.items
const failed = current.failed
const properties = embed.collection.properties.slice(0, 4)
const optionLabel = (prop: EmbeddedProperty, value: unknown) => {
if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No'
if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—'
if (prop.type === 'MULTI_SELECT') {
if (!Array.isArray(value)) return '—'
return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—'
}
if (prop.type === 'DATE' && typeof value === 'string' && value) {
if (prop.config.mode === 'date') return value.slice(0, 10)
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString('en-US', { timeZone: 'UTC' })
}
if (value === null || value === undefined || value === '') return '—'
return String(value)
}
const viewLabel = embed.view?.name ?? embed.collection.views[0]?.name
return (
<section className="not-prose my-4 overflow-hidden rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
<Link href="/collections" className="truncate text-sm font-medium hover:underline">{embed.collection.name}</Link>
{viewLabel && <span className="truncate text-xs text-muted-foreground">· {viewLabel}</span>}
<span className="ml-auto text-xs text-muted-foreground">{items ? `${items.length} item${items.length === 1 ? '' : 's'}` : ''}</span>
</div>
{failed ? (
<p className="px-3 py-4 text-sm text-muted-foreground">This collection could not be loaded.</p>
) : items === null ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Loading collection…</p>
) : items.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">No items match this view.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">Bookmark</th>
{properties.map((p) => <th key={p.id} className="px-3 py-2 font-medium">{p.name}</th>)}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t">
<td className="max-w-[18rem] px-3 py-2">
<Link href={`/bookmarks?open=${item.bookmark.id}`} className="block truncate hover:underline">
{item.bookmark.title || item.bookmark.ogTitle || item.bookmark.url}
</Link>
<span className="block truncate text-xs text-muted-foreground">{host(item.bookmark.url)}</span>
</td>
{properties.map((p) => <td key={p.id} className="max-w-[12rem] truncate px-3 py-2 text-muted-foreground">{optionLabel(p, item.propertyValues?.[p.id])}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
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 <p><a>. 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<PageSummary[]>([])
const [activeId, setActiveId] = useState<string | null>(initialId)
const [page, setPage] = useState<PageDetail | null>(null)
const [filter, setFilter] = useState('')
const [mode, setMode] = useState<'edit' | 'preview'>('preview')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [saveStates, setSaveStates] = useState<Record<string, PageSaveState>>({})
const [linkQuery, setLinkQuery] = useState('')
const [results, setResults] = useState<LinkedBookmark[]>([])
const [linkOpen, setLinkOpen] = useState(false)
const [pageLinkOpen, setPageLinkOpen] = useState(false)
const [pageLinkQuery, setPageLinkQuery] = useState('')
const [embedOpen, setEmbedOpen] = useState(false)
const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([])
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState<PageRevisionSummary[]>([])
const [revision, setRevision] = useState<PageRevisionDetail | null>(null)
const [historyBusy, setHistoryBusy] = useState<string | null>(null)
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<HTMLTextAreaElement>(null)
const pendingCaret = useRef<number | null>(null)
const suppressTabClick = useRef(false)
const draggedTabRef = useRef<string | null>(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<string, unknown>) => {
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)
}
// Insert generated Markdown at the caret (or at the end when the editor is
// not focused). The content stays the source of truth: page links and
// embeds are resolved by the server from this text.
const insertSnippet = (snippet: string) => {
if (!page || page.id !== activeId) return
const ta = textareaRef.current
const caret = mode === 'edit' && ta ? ta.selectionStart : content.length
const before = content.slice(0, caret)
const after = content.slice(caret)
const lead = before && !before.endsWith('\n') ? '\n\n' : ''
const trail = after.startsWith('\n') ? '' : '\n'
const next = before + lead + snippet + trail + after
pendingCaret.current = before.length + lead.length + snippet.length
setMode('edit')
onContentChange(next)
}
const setParent = async (value: string) => {
if (!page) return
const parentId = value || null
if (await patch({ parentId })) {
setPage((current) => current?.id === page.id ? { ...current, parentId } : current)
await loadPages(); notify()
}
}
const openHistory = async () => {
if (!page) return
setHistoryOpen(true)
setRevision(null)
try {
const res = await fetch(`/api/pages/${page.id}/revisions`, { cache: 'no-store' })
setRevisions(res.ok ? await res.json() : [])
if (!res.ok) toast.error('Could not load page history')
} catch { toast.error('Could not load page history') }
}
const viewRevision = async (id: string) => {
if (!page) return
setHistoryBusy(id)
try {
const res = await fetch(`/api/pages/${page.id}/revisions/${id}`, { cache: 'no-store' })
if (res.ok) setRevision(await res.json())
else toast.error('Could not load this version')
} catch { toast.error('Could not load this version') }
finally { setHistoryBusy(null) }
}
const restoreRevision = async (rev: PageRevisionSummary) => {
if (!page) return
if (!confirm(`Restore version ${rev.version}? The current content is kept in history.`)) return
setHistoryBusy(rev.id)
try {
if (!await autosave.flush(page.id)) { toast.error('Resolve the unsaved draft before restoring history'); return }
const latest = await fetch(`/api/pages/${page.id}`, { cache: 'no-store' })
if (!latest.ok) { toast.error('Could not reload the page'); return }
const current: PageDetail = await latest.json()
const res = await fetch(`/api/pages/${page.id}/revisions/${rev.id}/restore`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: current.version }),
})
if (res.status === 409) {
toast.error('The page changed elsewhere. Reload it before restoring history.')
await loadPage(page.id)
return
}
if (!res.ok) { toast.error((await res.json().catch(() => null))?.error ?? 'Could not restore this version'); return }
const restored: PageDetail = await res.json()
autosave.discard(page.id)
autosave.setVersion(page.id, restored.version)
setPage(restored)
setTitle(restored.title)
setContent(restored.content)
setMode('preview')
notifyActivePage(restored.title)
setHistoryOpen(false)
toast.success(`Restored version ${rev.version} as version ${restored.version}`)
await loadPages(); notify()
} catch { toast.error('Could not restore this version') }
finally { setHistoryBusy(null) }
}
// 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(`Move page "${page.title}" to the trash? Bookmarks are kept and it can be restored.`)) 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 (!embedOpen) return
fetch('/api/collections')
.then((res) => (res.ok ? res.json() : []))
.then((data) => setCollections(Array.isArray(data) ? data : []))
.catch(() => { /* transient; reopening retries */ })
}, [embedOpen])
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<HTMLTextAreaElement>) => {
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())
// A page may move under any page except itself or one of its descendants
// (the server rejects that cycle even if a stale client tries it).
const blockedParents = page ? descendantPageIds(pages, page.id) : new Set<string>()
const parentOptions = page
? pages.filter((p) => p.id !== page.id && !blockedParents.has(p.id))
: []
const pageLinkOptions = pages.filter((p) => p.id !== activeId && p.title.toLowerCase().includes(pageLinkQuery.toLowerCase()))
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border/60 px-4 py-2">
<div className="relative w-40 shrink-0">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter pages" className="h-8 pl-7 text-sm" />
</div>
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
{visible.map((p) => (
<div
key={p.id}
data-page-tab={p.id}
onDragOver={(e) => {
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() && (
<span
data-page-tab-drag
draggable
onDragStart={(e) => {
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}`}
>
<GripVertical className="h-3 w-3" />
</span>
)}
<button
type="button"
onClick={() => { if (!suppressTabClick.current) select(p.id) }}
aria-label={p.title}
className="flex min-w-0 items-center gap-1.5"
>
{p.pinned ? <Pin className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
<span className="max-w-[10rem] truncate">{p.title}</span>
</button>
</div>
))}
</div>
<Button size="sm" onClick={createPage}><Plus className="mr-1 h-4 w-4" />New page</Button>
</div>
{!page || page.id !== activeId ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
<FileText className="h-10 w-10 text-muted-foreground" />
<h2 className="font-display text-xl font-semibold">{pages.length ? 'Select a page' : 'No pages yet'}</h2>
<p className="max-w-sm text-sm text-muted-foreground">Pages are Markdown notes that collect links into a workspace. A bookmark can live on many pages.</p>
<Button onClick={createPage}><Plus className="mr-1 h-4 w-4" />Create a page</Button>
</div>
) : (
<div className="mx-auto w-full max-w-4xl flex-1 overflow-y-auto px-6 py-8">
<div className="mb-6 flex flex-wrap items-center gap-2">
<input value={title} onChange={(e) => 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" />
<span className="text-xs text-muted-foreground">{saveState === 'saving' ? 'Saving…' : saveState === 'dirty' ? 'Unsaved' : saveState === 'conflict' ? 'Conflict' : 'Saved'}</span>
{saveState === 'dirty' && <Button size="sm" variant="outline" onClick={() => autosave.flush(page.id)}>Save now</Button>}
<Button variant="ghost" size="icon-sm" title={mode === 'edit' ? 'Preview' : 'Edit'} onClick={() => { setMode(mode === 'edit' ? 'preview' : 'edit'); setSlash(null) }}>{mode === 'edit' ? <Eye className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}</Button>
<Button variant="ghost" size="icon-sm" title={page.pinned ? 'Unpin' : 'Pin to sidebar'} onClick={togglePin}>{page.pinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}</Button>
<Button variant="ghost" size="icon-sm" title="Move tab left" onClick={() => move(-1)}><ChevronLeft className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move tab right" onClick={() => move(1)}><ChevronRight className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Download .md" onClick={download}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Version history" onClick={() => void openHistory()}><History className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon-sm" title="Move to trash" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
<label className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
<CornerUpLeft className="h-3.5 w-3.5 shrink-0" />
<span className="shrink-0">Parent</span>
<select
value={page.parentId ?? ''}
onChange={(e) => void setParent(e.target.value)}
aria-label="Parent page"
className="h-8 max-w-[16rem] rounded-md border bg-background px-2 text-sm text-foreground"
>
<option value="">Top level</option>
{parentOptions.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
</label>
<Popover open={pageLinkOpen} onOpenChange={(open) => { setPageLinkOpen(open); if (!open) setPageLinkQuery('') }}>
<PopoverTrigger asChild><Button size="sm" variant="outline"><FileText className="mr-1 h-3.5 w-3.5" />Link a page</Button></PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<Input autoFocus value={pageLinkQuery} onChange={(e) => setPageLinkQuery(e.target.value)} placeholder="Find a page to link" className="h-8 text-sm" />
<div className="mt-2 max-h-64 overflow-y-auto">
{pageLinkOptions.map((p) => (
<button key={p.id} onClick={() => { insertSnippet(pageLinkMarkdown(p)); setPageLinkOpen(false) }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted">
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{p.title}</span>
</button>
))}
{pageLinkOptions.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching pages</p>}
</div>
</PopoverContent>
</Popover>
<Popover open={embedOpen} onOpenChange={setEmbedOpen}>
<PopoverTrigger asChild><Button size="sm" variant="outline"><Layers className="mr-1 h-3.5 w-3.5" />Embed a collection</Button></PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<div className="max-h-64 overflow-y-auto">
{collections.map((c) => (
<button key={c.id} onClick={() => { insertSnippet(collectionEmbedMarkdown(c.id, c.views?.[0]?.id)); setEmbedOpen(false) }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted">
<Layers className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{c.name}</span>
</button>
))}
{collections.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No collections yet. Create one from Collections first.</p>}
</div>
</PopoverContent>
</Popover>
</div>
{saveState === 'conflict' && (
<div role="alert" className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
<p className="font-medium text-foreground">This page was changed elsewhere</p>
<p className="mt-1 text-muted-foreground">
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.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="destructive" onClick={overwriteRemote}>Overwrite with my draft</Button>
<Button size="sm" variant="outline" onClick={copyDraft}>Save my draft as a new page</Button>
<Button size="sm" variant="outline" onClick={discardDraft}>Discard my draft and load latest</Button>
</div>
</div>
)}
{mode === 'edit' ? (
<div className="relative">
<Textarea
ref={textareaRef}
value={content}
onChange={(e) => { onContentChange(e.target.value); updateSlash(e.target.value, e.target.selectionStart) }}
onClick={(e) => updateSlash(e.currentTarget.value, e.currentTarget.selectionStart)}
onPaste={onPaste}
onBlur={() => setSlash(null)}
onKeyDown={(e) => {
// While the slash menu is open, arrows/Enter/Tab/Escape drive it.
if (slash && slashFiltered.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % slashFiltered.length); return }
if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + slashFiltered.length) % slashFiltered.length); return }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); applyBlock(slashFiltered[Math.min(slashIndex, slashFiltered.length - 1)]); return }
if (e.key === 'Escape') { e.preventDefault(); setSlash(null); return }
}
// Notion Markdown shortcut: "[]" + space becomes a to-do item.
if (e.key === ' ') {
const ta = e.currentTarget
const caret = ta.selectionStart
const lineStart = ta.value.lastIndexOf('\n', caret - 1) + 1
if (ta.value.slice(lineStart, caret) === '[]') {
e.preventDefault()
const next = ta.value.slice(0, lineStart) + '- [ ] ' + ta.value.slice(caret)
pendingCaret.current = lineStart + '- [ ] '.length
onContentChange(next)
}
}
}}
placeholder="Write Markdown notes… type / for blocks, paste links, lists, thoughts."
className="min-h-[260px] font-mono text-sm"
/>
{slash && slashFiltered.length > 0 && (
<div
onMouseDown={(e) => e.preventDefault()}
role="listbox"
aria-label="Blocks to insert"
className="absolute left-2 top-full z-20 mt-1 w-72 rounded-lg border bg-popover p-1 shadow-lg"
>
<p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Basic blocks</p>
<div className="max-h-64 overflow-y-auto">
{slashFiltered.map((b, i) => (
<button
key={b.key}
type="button"
role="option"
aria-selected={i === slashIndex}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => applyBlock(b)}
className={cn('flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left', i === slashIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50')}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded border bg-card">
<b.icon className="h-3.5 w-3.5 text-muted-foreground" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{b.label}</span>
<span className="block truncate text-xs text-muted-foreground">{b.desc}</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
) : (
<div className="prose prose-sm max-w-none dark:prose-invert min-h-[80px] cursor-text" onDoubleClick={() => setMode('edit')}>
{content ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => {
// A paragraph that is exactly an embed marker renders a
// live, read-only collection view instead of raw text.
const marker = node ? parsePageEmbed(textOf(node.children)) : null
if (marker) {
const embed = page.embeds.find((e) => e.collectionId === marker.collectionId)
return embed ? (
<PageEmbedCard embed={embed} />
) : (
<p className="text-xs text-muted-foreground">This embedded collection is unavailable or not yours.</p>
)
}
const link = standaloneLink(node)
return link ? (
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
) : (
<p {...props} />
)
},
a: ({ href, ...props }) => {
const target = href ? internalPageId(href) : null
if (target) {
return <Link href={`/pages?id=${target}`} className="text-primary underline-offset-2 hover:underline" {...props} />
}
return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />
},
}}
>{content}</ReactMarkdown>
) : (
<p className="text-muted-foreground">No notes yet. Double-click or press the pencil to write.</p>
)}
</div>
)}
<div className="mt-10 mb-3 flex items-center justify-between">
<h3 className="font-display text-lg font-semibold">Links on this page <span className="text-sm font-normal text-muted-foreground">({page.bookmarks.length})</span></h3>
<Popover open={linkOpen} onOpenChange={setLinkOpen}>
<PopoverTrigger asChild><Button size="sm" variant="outline"><Link2 className="mr-1 h-4 w-4" />Add link</Button></PopoverTrigger>
<PopoverContent align="end" className="w-80 p-2">
<Input autoFocus value={linkQuery} onChange={(e) => setLinkQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && looksLikeUrl && addUrl()} placeholder="Search bookmarks or paste a URL" className="h-8 text-sm" />
<div className="mt-2 max-h-64 overflow-y-auto">
{looksLikeUrl && (
<button onClick={addUrl} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted"><Plus className="h-3.5 w-3.5" />Save &amp; add “{linkQuery.trim()}”</button>
)}
{results.filter((r) => !page.bookmarks.some((x) => x.bookmark.id === r.id)).map((r) => (
<button key={r.id} onClick={() => addBookmark(r.id)} className="flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-muted">
<span className="truncate text-sm">{r.title || r.ogTitle || r.url}</span>
<span className="truncate text-xs text-muted-foreground">{host(r.url)}</span>
</button>
))}
{!looksLikeUrl && results.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching bookmarks</p>}
</div>
</PopoverContent>
</Popover>
</div>
{page.bookmarks.length === 0 ? (
<div className="rounded-lg bg-muted/40 p-8 text-center text-sm text-muted-foreground">No links yet. Add existing bookmarks or paste a new URL.</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{page.bookmarks.map(({ bookmark: b }) => (
<div key={b.id} className="group relative flex gap-3 rounded-lg bg-card p-3 shadow-sm ring-1 ring-border/50 transition-shadow hover:shadow-md">
{b.ogImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={b.ogImage} alt={`Preview of ${b.title || host(b.url)}`} referrerPolicy="no-referrer" className="h-16 w-24 shrink-0 rounded object-cover bg-muted" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
) : null}
<a href={b.url} target="_blank" rel="noopener noreferrer" className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{b.favicon && (
// eslint-disable-next-line @next/next/no-img-element
<img src={b.favicon} alt="" referrerPolicy="no-referrer" className="h-3.5 w-3.5" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
)}
<span className="truncate text-sm font-medium">{b.title || b.ogTitle || b.url}</span>
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{b.description || b.ogDescription || ''}</p>
<p className="mt-1 text-[11px] text-muted-foreground/80">{host(b.url)}</p>
</a>
<button onClick={() => removeBookmark(b.id)} title="Remove from page" className="absolute right-1.5 top-1.5 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted group-hover:opacity-100"><X className="h-3.5 w-3.5" /></button>
</div>
))}
</div>
)}
{page.backlinks.length > 0 && (
<section className="mt-8" aria-label="Pages linking here">
<h3 className="mb-2 font-display text-lg font-semibold">
Linked from <span className="text-sm font-normal text-muted-foreground">({page.backlinks.length})</span>
</h3>
<div className="flex flex-wrap gap-2">
{page.backlinks.map((b) => (
<button
key={b.id}
type="button"
onClick={() => void select(b.id)}
className="flex max-w-full items-center gap-1.5 rounded-md border bg-card px-2.5 py-1.5 text-sm transition-colors hover:bg-accent"
>
<CornerUpLeft className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{b.title}</span>
</button>
))}
</div>
</section>
)}
</div>
)}
<Dialog open={historyOpen} onOpenChange={(open) => { setHistoryOpen(open); if (!open) setRevision(null) }}>
<DialogContent className="max-w-3xl">
<DialogHeader><DialogTitle>Version history</DialogTitle></DialogHeader>
<p className="text-xs text-muted-foreground">
Every saved content version is kept. Restoring writes a new version, so nothing in history is ever rewritten.
</p>
<div className="grid gap-3 md:grid-cols-[15rem_1fr]">
<div className="max-h-[24rem] space-y-1 overflow-y-auto rounded-md border p-1">
{revisions.map((rev) => (
<div key={rev.id} className={cn('rounded-md px-2 py-1.5', revision?.id === rev.id ? 'bg-accent' : 'hover:bg-muted')}>
<button type="button" className="flex w-full items-center justify-between gap-2 text-left" onClick={() => void viewRevision(rev.id)}>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">Version {rev.version}</span>
<span className="block text-xs text-muted-foreground">{new Date(rev.createdAt).toLocaleString('en-US', { timeZone: 'UTC' })}</span>
</span>
{revisions[0]?.id === rev.id && <span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">Current</span>}
</button>
<button
type="button"
disabled={historyBusy === rev.id}
onClick={() => void restoreRevision(rev)}
className="mt-1 flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-50"
>
<RotateCcw className="h-3 w-3" /> Restore
</button>
</div>
))}
{revisions.length === 0 && <p className="px-2 py-6 text-center text-xs text-muted-foreground">No saved versions yet.</p>}
</div>
<div className="max-h-[24rem] overflow-y-auto rounded-md border bg-muted/20 p-3">
{revision ? (
<>
<p className="mb-2 text-sm font-medium">{revision.title}</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">{revision.content || '(empty page)'}</pre>
</>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">Select a version to preview it.</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}