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
+245 -5
View File
@@ -1,13 +1,15 @@
'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, Download, Eye, FileText, GripVertical, Heading1, Heading2, Heading3,
Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, Plus, Quote, Search, Trash2, Type, X,
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
Heading1, Heading2, Heading3, Layers, 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'
@@ -15,7 +17,10 @@ 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 {
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'
@@ -40,11 +45,118 @@ const SLASH_BLOCKS = [
] 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 }[] }
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[]
}
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()
}
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('')
@@ -76,6 +188,10 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
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 [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
const [slashIndex, setSlashIndex] = useState(0)
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
@@ -246,6 +362,32 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
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()
}
}
// 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
@@ -372,6 +514,14 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
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()
@@ -448,6 +598,13 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
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">
@@ -540,6 +697,51 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
<Button variant="ghost" size="icon-sm" title="Delete page" 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>
@@ -628,6 +830,17 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
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} />
@@ -635,7 +848,13 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
<p {...props} />
)
},
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
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>
) : (
@@ -692,6 +911,27 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
))}
</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>
)}
</div>
+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" />
+7 -1
View File
@@ -85,11 +85,17 @@ Composite PK (`tagId`, `userId`), `createdAt`; both Cascade.
`id`, `label`, `color` (default `#64748b`), `position` int, `key?` (`INBOX`/`TODO`/`IN_PROGRESS`/`DONE` for the default set, null for custom columns), `userId` → User (Cascade). Index (`userId`, `position`).
### 2.12 `Page` (Markdown workspace)
`id`, `title`, `content` (text, default empty), `icon?`, `pinned` bool, `position` int, `version` int (default 1; optimistic-concurrency revision — content saves bump it, stale base versions get 409), `ownerId` → User (Cascade). Index (`ownerId`, `position`).
`id`, `title`, `content` (text, default empty), `icon?`, `pinned` bool, `position` int, `parentId?` → Page (SetNull; nested-page tree, cycles rejected by the API), `version` int (default 1; optimistic-concurrency revision — content saves bump it, stale base versions get 409), `ownerId` → User (Cascade). Indexes (`ownerId`, `position`) and (`parentId`).
### 2.13 `PageBookmark` (link a bookmark onto a page)
Composite PK (`pageId`, `bookmarkId`), `position` int, `createdAt`; both Cascade.
### 2.13b `PageLink` (resolved internal page link)
Composite PK (`sourcePageId`, `targetPageId`), `createdAt`; both Cascade, with a no-self check. Rebuilt from Markdown (`computer:page/<id>`) on every content save and only for pages owned by the author, so backlinks never cross accounts.
### 2.13c `PageEmbed` (live collection view inside a page)
Composite PK (`pageId`, `collectionId`), `viewId?` → CollectionView (SetNull), `position` int, `createdAt`; page and collection Cascade. Rebuilt from `{{collection:<id>|view=<viewId>}}` on every content save; only owned collections are stored. Item reads still go through the collection endpoint, so an embed displays, never publishes.
### 2.14 `SignalSource` (a watcher attached to a bookmark)
`id`, `type` (e.g. `email`), `config` (JSON, default `{}`), `enabled` (default true), `lastCheckedAt?`, `lastStatus?`, `bookmarkId` → Bookmark (Cascade), `ownerId` → User (Cascade).
+64 -1
View File
@@ -9,7 +9,70 @@ export const ACTIVE_PAGE_CHANGED = 'computer:active-page-changed'
export const notifyActivePage = (title: string | null) =>
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
export type PageSummary = { id: string; title: string; pinned: boolean; position: number; _count?: { bookmarks: number } }
export type PageSummary = {
id: string
title: string
pinned: boolean
position: number
parentId: string | null
_count?: { bookmarks: number; children?: number }
}
// Connected pages (phase 3): the same link forms the editor understands,
// shared by the page client, sidebar and Markdown preview. Page links use
// `computer:page/<id>`; /pages?id=<id> is accepted for hand-written links.
export function internalPageId(href: string): string | null {
const value = href.trim().replace(/^<|>$/g, '')
let raw = ''
if (value.startsWith('computer://page/')) raw = value.slice('computer://page/'.length)
else if (value.startsWith('computer:page/')) raw = value.slice('computer:page/'.length)
else if (value.startsWith('page/')) raw = value.slice('page/'.length)
else if (value.startsWith('/pages/')) raw = value.slice('/pages/'.length)
else if (value.startsWith('/pages?') || value.startsWith('computer:page?id=') || value.startsWith('computer://page?id=')) {
try {
const id = new URL(value, 'http://localhost').searchParams.get('id')
raw = id ?? ''
} catch { raw = '' }
}
raw = raw.replace(/\/$/, '')
return /^[A-Za-z0-9_-]{1,128}$/.test(raw) ? raw : null
}
export const pageLinkMarkdown = (page: Pick<PageSummary, 'id' | 'title'>) =>
`[${page.title.replace(/[\[\]\n]/g, ' ')}](computer:page/${page.id})`
export type PageEmbedMarker = { collectionId: string; viewId: string | null }
export function parsePageEmbed(value: string): PageEmbedMarker | null {
const m = /^\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}$/.exec(value.trim())
if (!m) return null
return { collectionId: m[1], viewId: m[2] ?? null }
}
export const collectionEmbedMarkdown = (collectionId: string, viewId?: string | null) =>
`{{collection:${collectionId}${viewId ? `|view=${viewId}` : ''}}}`
// Ids below `id` in the current page tree. Used to keep the parent picker
// from offering a descendant (the server rejects the cycle regardless).
export function descendantPageIds(pages: PageSummary[], id: string): Set<string> {
const out = new Set<string>()
const children = new Map<string, string[]>()
for (const p of pages) {
if (!p.parentId) continue
const list = children.get(p.parentId) ?? []
list.push(p.id)
children.set(p.parentId, list)
}
const walk = (parent: string) => {
for (const child of children.get(parent) ?? []) {
if (out.has(child)) continue
out.add(child)
walk(child)
}
}
walk(id)
return out
}
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
// Pick the lowest positive integer not already used by an untitled page.