initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+699
View File
@@ -0,0 +1,699 @@
'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 <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 [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)
}
// 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<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())
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="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</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 }) => {
const link = standaloneLink(node)
return link ? (
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
) : (
<p {...props} />
)
},
a: ({ node, ...props }) => <a {...props} 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>
)}
</div>
)}
</div>
)
}