Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s
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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user