Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s

Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
This commit is contained in:
Andrew S Erwin
2026-10-19 22:37:21 -05:00
parent b9721ff13f
commit 9db69efe4b
12 changed files with 938 additions and 28 deletions
+75 -8
View File
@@ -151,6 +151,7 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null)
const [collapsedPages, setCollapsedPages] = useState<Set<string>>(() => new Set())
const draggedPageRef = useRef<string | null>(null)
const activePageId = pathname === '/pages' ? searchParams.get('id') : null
@@ -167,12 +168,16 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
return () => window.removeEventListener(PAGES_CHANGED, load)
}, [load])
const createPage = async () => {
const createPage = async (parentId?: string | null) => {
const title = nextUntitledTitle(pages.map((p) => p.title))
try {
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) })
const res = await fetch('/api/pages', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parentId ? { title, parentId } : { title }),
})
if (!res.ok) { toast.error('Could not create page'); return }
const p = await res.json()
if (parentId) setCollapsedPages((current) => { const next = new Set(current); next.delete(parentId); return next })
notifyPagesChanged()
router.push(`/pages?id=${p.id}`)
} catch { toast.error('Could not create page') }
@@ -234,16 +239,56 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
if (next) void savePageOrder(next)
}
const toggleCollapsed = (id: string) => {
setCollapsedPages((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// Flatten the owner's page tree for display. The server already orders the
// list (pinned first, then position); preserving that order per parent keeps
// pinned pages predictable while nested children follow their parent.
const pageRows: { page: PageSummary; depth: number; hasChildren: boolean }[] = []
{
const known = new Set(pages.map((p) => p.id))
const byParent = new Map<string | null, PageSummary[]>()
for (const p of pages) {
const key = p.parentId && known.has(p.parentId) ? p.parentId : null
const list = byParent.get(key) ?? []
list.push(p)
byParent.set(key, list)
}
const visited = new Set<string>()
const visit = (parent: string | null, depth: number) => {
for (const p of byParent.get(parent) ?? []) {
if (visited.has(p.id)) continue
visited.add(p.id)
const children = byParent.get(p.id) ?? []
pageRows.push({ page: p, depth, hasChildren: children.length > 0 })
if (!collapsedPages.has(p.id)) visit(p.id, depth + 1)
}
}
visit(null, 0)
// Defensive: a corrupt/cyclic pre-existing hierarchy must still render
// every page once rather than disappear from the sidebar.
for (const p of pages) {
if (!visited.has(p.id)) pageRows.push({ page: p, depth: 0, hasChildren: (byParent.get(p.id) ?? []).length > 0 })
}
}
const viewEntries = isAdmin ? [...viewItems, { href: '/admin', label: 'Admin', icon: Shield }] : viewItems
return (
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
<SectionHeader label="Pages" open={open} expanded={pagesExpanded} onToggle={() => setPagesExpanded(!pagesExpanded)} action={{ label: 'New page', onClick: createPage }} />
{(pagesExpanded || !open) && pages.map((p) => {
<SectionHeader label="Pages" open={open} expanded={pagesExpanded} onToggle={() => setPagesExpanded(!pagesExpanded)} action={{ label: 'New page', onClick: () => void createPage() }} />
{(pagesExpanded || !open) && pageRows.map(({ page: p, depth, hasChildren }) => {
const isActive = pathname === '/pages' && activePageId === p.id
if (renamingId === p.id) {
return (
<div key={p.id} className="px-1 py-0.5">
<div key={p.id} className="px-1 py-0.5" style={{ paddingLeft: depth * 12 }}>
<Input
autoFocus
value={renameValue}
@@ -278,12 +323,24 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
const rect = e.currentTarget.getBoundingClientRect()
dropPage(p, e.clientY > rect.top + rect.height / 2)
}}
style={{ paddingLeft: depth * 12 }}
className={cn(
'group/row flex items-center rounded-lg pr-1 transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
pageDrop?.id === p.id && 'ring-1 ring-primary/50'
)}
>
{open && (hasChildren ? (
<button
type="button"
onClick={() => toggleCollapsed(p.id)}
aria-label={collapsedPages.has(p.id) ? `Expand ${p.title}` : `Collapse ${p.title}`}
aria-expanded={!collapsedPages.has(p.id)}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className={cn('h-3 w-3 transition-transform', collapsedPages.has(p.id) && '-rotate-90')} />
</button>
) : <span className="w-4 shrink-0" aria-hidden />)}
{open && (
<span
data-page-drag-handle
@@ -308,9 +365,9 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
{open && (
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
<button
onClick={createPage}
title="New page"
aria-label="New page"
onClick={() => void createPage(p.id)}
title="New subpage"
aria-label={`New subpage under ${p.title}`}
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
@@ -330,6 +387,16 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void createPage(p.id)}>
<Plus className="mr-2 h-3.5 w-3.5" />
New subpage
</DropdownMenuItem>
{p.parentId && (
<DropdownMenuItem onClick={() => patchPage(p.id, { parentId: null })}>
<ChevronRight className="mr-2 h-3.5 w-3.5" />
Move to top level
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => removePage(p)}>
<Trash2 className="mr-2 h-3.5 w-3.5" />