diff --git a/API.md b/API.md index 611c48a..2ad89f4 100644 --- a/API.md +++ b/API.md @@ -111,14 +111,34 @@ test-only). CardDAV keeps the configured HTTPS port. | PATCH | `/api/kanban/columns/{id}` | Updated column; body `{label?,color?}` | | DELETE | `/api/kanban/columns/{id}` | `{ok:true,movedTo,moved}`; moves cards, never deletes bookmarks; refuses last column | | PUT | `/api/kanban/cards/{id}` | Moved bookmark; body `{columnId,position,version}`. Reorders the target column (0-based position, clamped at the end) and normalizes every card's `kanbanPosition`. Owner only. `version` is REQUIRED: 400 when missing/invalid, 409 when stale; success bumps the moved card's `version` | -| GET | `/api/pages?search=...` | Pages, pinned first, with `_count.bookmarks`; title/content substring search treats `%` and `_` literally and uses trigram indexes | -| POST | `/api/pages` | 201 page; body `{title?,content?}` | +| GET | `/api/pages?search=...` | Pages, pinned first, with `parentId` and `_count.{bookmarks,children}`; title/content substring search treats `%` and `_` literally and uses trigram indexes | +| POST | `/api/pages` | 201 page; body `{title?,content?,parentId?}`. `parentId` must be an owned page; a missing/foreign parent is 400 | | PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` | -| GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]` | -| PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches stay version-free and do not bump the revision | -| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks | +| GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]`, owner-scoped `backlinks:[{id,title,updatedAt}]`, resolved outgoing `links:[{id,title}]`, and `embeds:[{collectionId,viewId,position,collection,view}]` | +| PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,parentId?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches (including moving a page) stay version-free and do not bump the revision. Moving under itself or a descendant is rejected 400 (no cycles) | +| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks and promotes child pages to the top level | +| GET | `/api/pages/{id}/backlinks` | Owner-scoped `[{id,title,updatedAt}]` from `PageLink` — the pages whose Markdown links here | | GET | `/api/pages/{id}/markdown` | Markdown attachment | +### Connected pages (phase 3) + +Page hierarchy, internal links and live collection embeds are ordinary +Markdown contained in the page body; the server rebuilds the resolved graph +in the same transaction as every content save: + +- `[label](computer:page/)` links to another page. `/pages?id=` + and `page/` are accepted as hand-written aliases. Only owned targets + are stored, so a backlink can never reveal another account's page title; + self-links are ignored. +- `{{collection:}}` embeds the collection's live default view; + `{{collection:|view=}}` pins a saved view's + filters/sorts. Only owned collections are stored, and item reads still go + through the normal collection endpoint, so an embed displays data without + publishing it or widening bookmark access. +- Deleting a page removes its links/embeds, promotes its children, and keeps + its bookmarks. Deleting a collection removes its embeds; deleting a view + falls the embed back to the collection's default view. + ## Collections Contextual workflows over existing bookmarks (phase 2 of the Notion-like diff --git a/PLAN.md b/PLAN.md index 49b9e80..28cf99f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -83,7 +83,6 @@ keeping its own passwords.* - Collections can't be shared — they're private to their owner. - No page history (can't see or restore older versions). - No trash — deletes are permanent. -- No nested pages or backlinks ("which pages mention this one?"). - Calendar and list views for collections are stored but still display as tables. - Alerts only show inside the app — nothing is emailed or pushed to you. @@ -171,8 +170,9 @@ maintenance rather than a separate milestone. - **Page history and trash** — see old versions, restore them; deleted things are recoverable. -- **Nested pages and backlinks** — a real page tree, and "what links here" - that never shows titles you're not allowed to see. +- **Nested pages and backlinks** — done: a real page tree, "what links here" + that never shows titles you're not allowed to see, and live collection + views embedded in pages. - **Collection sharing** — the big one. Explicit, shown in the UI, and revocable. Design the permission rules first (a project must never accidentally widen who can see a bookmark), then build it. Once it diff --git a/db/migrations/008_connected_pages.sql b/db/migrations/008_connected_pages.sql new file mode 100644 index 0000000..f3f0474 --- /dev/null +++ b/db/migrations/008_connected_pages.sql @@ -0,0 +1,57 @@ +-- 008_connected_pages.sql — Notion-like upgrades phase 3. +-- +-- Nested pages, resolved internal page links (backlinks), and live collection +-- views embedded in a page. Links and embeds are derived from Markdown on +-- every content save: the page body stays the single source of truth. +-- +-- Access rules: +-- * Pages remain owner-scoped. A link to a page the author does not own is +-- ignored, so backlinks can never reveal a title across accounts. +-- * An embed is stored only when the author owns the collection. The live +-- view still re-checks bookmark visibility through the collection item +-- endpoints (N-08); embedding never publishes anything. +-- +-- Deleting: +-- * Deleting a parent page promotes its children (SET NULL), never deletes +-- them silently. +-- * Deleting a page cascades its outgoing/incoming links and embeds. +-- * Deleting a collection cascades its embeds; deleting a view clears the +-- embed's viewId so it falls back to the collection's default view. + +ALTER TABLE "Page" ADD COLUMN IF NOT EXISTS "parentId" text; + +-- The named constraint matches schema.sql so the fresh and upgraded paths +-- converge. The guard makes a re-run against an already-current schema a +-- no-op (without it, schema.sql's inline FK would collide). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'Page_parentId_fkey' AND conrelid = '"Page"'::regclass + ) THEN + ALTER TABLE "Page" ADD CONSTRAINT "Page_parentId_fkey" + FOREIGN KEY ("parentId") REFERENCES "Page"(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS "Page_parentId_idx" ON "Page"("parentId"); + +CREATE TABLE IF NOT EXISTS "PageLink" ( + "sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("sourcePageId", "targetPageId"), + CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId") +); +CREATE INDEX IF NOT EXISTS "PageLink_targetPageId_idx" ON "PageLink"("targetPageId"); + +CREATE TABLE IF NOT EXISTS "PageEmbed" ( + "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + "viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL, + position integer NOT NULL DEFAULT 0, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("pageId", "collectionId") +); +CREATE INDEX IF NOT EXISTS "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId"); +CREATE INDEX IF NOT EXISTS "PageEmbed_viewId_idx" ON "PageEmbed"("viewId"); diff --git a/db/schema.sql b/db/schema.sql index f7db726..45da369 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -196,6 +196,10 @@ CREATE TABLE "Page" ( icon text, pinned boolean NOT NULL DEFAULT false, position int NOT NULL DEFAULT 0, + -- Connected pages (phase 3): a page may live under another page. Deleting + -- the parent promotes its children to the top level instead of deleting + -- them; cycle rejection is enforced by the API. + "parentId" text REFERENCES "Page"(id) ON DELETE SET NULL, -- Optimistic concurrency revision (review DATA-01): content saves bump it; -- a stale base version in PATCH is rejected with 409. version integer NOT NULL DEFAULT 1, @@ -204,6 +208,7 @@ CREATE TABLE "Page" ( "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "Page_ownerId_position_idx" ON "Page"("ownerId", position); +CREATE INDEX "Page_parentId_idx" ON "Page"("parentId"); -- Index-backed substring search over the two searchable fields. CREATE INDEX "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops); CREATE INDEX "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops); @@ -218,6 +223,19 @@ CREATE TABLE "PageBookmark" ( ); CREATE INDEX "PageBookmark_bookmarkId_idx" ON "PageBookmark"("bookmarkId"); +-- 2.13b PageLink: resolved internal page links, rebuilt from page content on +-- every content save. Targets are only stored when the author owns the target +-- page, so a backlink can never reveal an inaccessible title. Self-links are +-- ignored by the writer and refused here as defense in depth. +CREATE TABLE "PageLink" ( + "sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("sourcePageId", "targetPageId"), + CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId") +); +CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId"); + -- 2.14 SignalSource CREATE TABLE "SignalSource" ( id text PRIMARY KEY, @@ -348,4 +366,21 @@ CREATE TABLE "CollectionView" ( "updatedAt" timestamptz NOT NULL DEFAULT now() ); CREATE INDEX "CollectionView_collectionId_idx" ON "CollectionView"("collectionId"); + +-- 2.18 PageEmbed (Notion-like upgrades phase 3): a live collection view +-- embedded in a page. The reference is resolved from the page's Markdown +-- (`{{collection:|view=}}`) on every content save. Only collections +-- owned by the page's author are stored; embedding displays data and never +-- publishes it or widens bookmark access. Deleting the collection or the +-- chosen view removes/falls back rather than leaving a dangling embed. +CREATE TABLE "PageEmbed" ( + "pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE, + "collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE, + "viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL, + position integer NOT NULL DEFAULT 0, + "createdAt" timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY ("pageId", "collectionId") +); +CREATE INDEX "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId"); +CREATE INDEX "PageEmbed_viewId_idx" ON "PageEmbed"("viewId"); COMMIT; diff --git a/internal/handlers/page_graph.go b/internal/handlers/page_graph.go new file mode 100644 index 0000000..f4750cd --- /dev/null +++ b/internal/handlers/page_graph.go @@ -0,0 +1,196 @@ +package handlers + +import ( + "context" + "database/sql" + "net/url" + "regexp" + "strings" + + "github.com/andrew/go-computer/internal/db" +) + +// Connected pages (Notion-like upgrades phase 3). +// +// Page hierarchy, internal links and embedded collection views are all +// expressed in the page's Markdown, which stays the single source of truth: +// +// - [Any label](computer:page/) links to another page. /pages?id= +// and page/ are accepted as aliases so hand-written links work too. +// - {{collection:}} embeds a live collection view; an optional +// {{collection:|view=}} pins a saved view's filters/sorts. +// +// On every content save the resolved PageLink and PageEmbed rows are rebuilt +// inside the same transaction. Only targets owned by the author are stored: +// backlinks therefore cannot enumerate or reveal titles from another account. +// Embedding displays data through the normal collection endpoints, so the +// underlying bookmark-visibility checks still apply and nothing is published. + +var ( + markdownLinkRE = regexp.MustCompile(`\[[^\]]*\]\(\s*]+)(?:\s+"[^"]*")?\s*>?\)`) + pageEmbedRE = regexp.MustCompile(`\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}`) + pageRefRE = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) +) + +// internalPageID normalizes a Markdown link target to a page id. It returns +// false for ordinary web links and malformed ids. +func internalPageID(href string) (string, bool) { + href = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(href, "<"), ">")) + raw := "" + switch { + case strings.HasPrefix(href, "computer://page/"): + raw = strings.TrimPrefix(href, "computer://page/") + case strings.HasPrefix(href, "computer:page/"): + raw = strings.TrimPrefix(href, "computer:page/") + case strings.HasPrefix(href, "page/"): + raw = strings.TrimPrefix(href, "page/") + case strings.HasPrefix(href, "/pages/"): + raw = strings.TrimPrefix(href, "/pages/") + case strings.HasPrefix(href, "/pages?"): + if u, err := url.Parse(href); err == nil { + raw = u.Query().Get("id") + } + case strings.HasPrefix(href, "computer:page?id="): + if u, err := url.Parse(href); err == nil { + raw = u.Query().Get("id") + } + case strings.HasPrefix(href, "computer://page?id="): + if u, err := url.Parse(href); err == nil { + raw = u.Query().Get("id") + } + } + raw = strings.TrimSuffix(strings.TrimSpace(raw), "/") + if !pageRefRE.MatchString(raw) { + return "", false + } + return raw, true +} + +type pageEmbedRef struct { + collectionID string + viewID string +} + +// pageGraph extracts the distinct page links and embeds from Markdown. +func pageGraph(content, selfID string) ([]string, []pageEmbedRef) { + links := make([]string, 0) + seenLinks := map[string]bool{} + for _, m := range markdownLinkRE.FindAllStringSubmatchIndex(content, -1) { + // Skip images: ![alt](target) is not an internal link. + if m[0] > 0 && content[m[0]-1] == '!' { + continue + } + id, ok := internalPageID(content[m[2]:m[3]]) + if !ok || id == selfID || seenLinks[id] { + continue + } + seenLinks[id] = true + links = append(links, id) + } + embeds := make([]pageEmbedRef, 0) + seenEmbeds := map[string]bool{} + for _, m := range pageEmbedRE.FindAllStringSubmatch(content, -1) { + ref := pageEmbedRef{collectionID: m[1], viewID: m[2]} + if seenEmbeds[ref.collectionID] { + continue + } + seenEmbeds[ref.collectionID] = true + embeds = append(embeds, ref) + } + return links, embeds +} + +// syncPageGraph replaces one page's resolved links and embeds. It runs inside +// the content-save transaction, so the derived rows and the Markdown commit +// together (a failed save never leaves stale backlinks). +func syncPageGraph(ctx context.Context, tx *sql.Tx, pageID, uid, content string) error { + links, embeds := pageGraph(content, pageID) + + if _, err := tx.ExecContext(ctx, `DELETE FROM "PageLink" WHERE "sourcePageId"=$1`, pageID); err != nil { + return err + } + for _, target := range links { + var visible bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, target, uid).Scan(&visible); err != nil { + return err + } + // A foreign/missing target is ignored rather than stored. The link + // text remains in the author's Markdown, but it can never become a + // cross-account backlink. + if !visible { + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO "PageLink"("sourcePageId","targetPageId") VALUES($1,$2) ON CONFLICT DO NOTHING`, pageID, target); err != nil { + return err + } + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM "PageEmbed" WHERE "pageId"=$1`, pageID); err != nil { + return err + } + for i, ref := range embeds { + var visible bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, ref.collectionID, uid).Scan(&visible); err != nil { + return err + } + if !visible { + continue + } + viewID := any(nil) + if ref.viewID != "" { + var valid bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "CollectionView" WHERE id=$1 AND "collectionId"=$2)`, ref.viewID, ref.collectionID).Scan(&valid); err != nil { + return err + } + // An unknown view falls back to the collection's default view + // instead of dropping the embed entirely. + if valid { + viewID = ref.viewID + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO "PageEmbed"("pageId","collectionId","viewId",position) VALUES($1,$2,$3,$4)`, pageID, ref.collectionID, viewID, i); err != nil { + return err + } + } + return nil +} + +// pageParent validates a requested parent and rejects cycles. The walk stays +// within the owner's pages and is bounded by the seen set, so a corrupt +// pre-existing cycle cannot loop forever. +func pageParent(ctx context.Context, q db.Queryer, pageID, uid string, raw any) (any, error) { + if raw == nil { + return nil, nil + } + parent, ok := raw.(string) + if !ok { + return nil, bad("parentId must be a page id or null") + } + parent = strings.TrimSpace(parent) + if parent == "" { + return nil, nil + } + if !pageRefRE.MatchString(parent) { + return nil, bad("Invalid parentId") + } + if parent == pageID { + return nil, bad("A page cannot be its own parent") + } + seen := map[string]bool{pageID: true} + for current := parent; current != ""; { + if seen[current] { + return nil, bad("Page hierarchy cannot contain a cycle") + } + seen[current] = true + var next sql.NullString + err := q.QueryRowContext(ctx, `SELECT "parentId" FROM "Page" WHERE id=$1 AND "ownerId"=$2`, current, uid).Scan(&next) + if err == sql.ErrNoRows { + return nil, bad("Parent page not found") + } + if err != nil { + return nil, err + } + current = next.String + } + return parent, nil +} diff --git a/internal/handlers/pages.go b/internal/handlers/pages.go index e482a0c..776a4ae 100644 --- a/internal/handlers/pages.go +++ b/internal/handlers/pages.go @@ -18,7 +18,23 @@ import ( func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) { // Linked bookmarks use the viewer projection (review PRIV-02): a page can // link bookmarks shared with its owner, and those arrive narrowed. - return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid) + // + // Connected pages (phase 3): backlinks and embeds are owner-scoped in the + // query itself, not trusted from the caller. `links` is the resolved + // outgoing edge set (useful for navigation); the Markdown remains the + // source of truth and is rebuilt by syncPageGraph on every content save. + return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object( + 'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb), + 'backlinks',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") ORDER BY s."updatedAt" DESC,s.id) FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=p.id AND s."ownerId"=p."ownerId"),'[]'::jsonb), + 'links',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'title',t.title) ORDER BY t.title,t.id) FROM "PageLink" pl JOIN "Page" t ON t.id=pl."targetPageId" WHERE pl."sourcePageId"=p.id AND t."ownerId"=p."ownerId"),'[]'::jsonb), + 'embeds',COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'collectionId',e."collectionId",'viewId',e."viewId",'position',e.position, + 'collection',to_jsonb(c)||jsonb_build_object( + 'properties',COALESCE((SELECT jsonb_agg(to_jsonb(pr) ORDER BY pr.position,pr.id) FROM "CollectionProperty" pr WHERE pr."collectionId"=c.id),'[]'::jsonb), + 'views',COALESCE((SELECT jsonb_agg(to_jsonb(v) ORDER BY v.position,v.id) FROM "CollectionView" v WHERE v."collectionId"=c.id),'[]'::jsonb)), + 'view',(SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=e."viewId")) + ORDER BY e.position,e."collectionId") FROM "PageEmbed" e JOIN "Collection" c ON c.id=e."collectionId" WHERE e."pageId"=p.id AND c."ownerId"=p."ownerId"),'[]'::jsonb) + ) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid) } func page(r *http.Request, id string) (object, error) { return pageView(r.Context(), database(r), id, user(r)) @@ -31,12 +47,15 @@ func HandlePagesList(w http.ResponseWriter, r *http.Request) { // literal characters, not patterns. The pg_trgm indexes on title/content // let Postgres use the same ILIKE predicate without scanning every page. search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%" - v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search) + v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object( + 'bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id), + 'children',(SELECT count(*) FROM "Page" ch WHERE ch."parentId"=p.id AND ch."ownerId"=p."ownerId"))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search) respond(w, 200, v, e) } func pageData(b object) (object, error) { - d := fields(b, "title content icon pinned") - if e := stringFields(d, "icon", "title content"); e != nil { + d := fields(b, "title content icon pinned parentId") + // parentId is nullable: an explicit null promotes the page to the root. + if e := stringFields(d, "icon parentId", "title content"); e != nil { return nil, e } if e := boolFields(d, "pinned"); e != nil { @@ -80,6 +99,13 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) { if e := lockUser(r, tx); e != nil { return e } + if raw, ok := d["parentId"]; ok { + parent, e := pageParent(r.Context(), tx, d["id"].(string), user(r), raw) + if e != nil { + return e + } + d["parentId"] = parent + } var n int if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil { return e @@ -87,6 +113,17 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) { d["position"] = n var e error v, e = db.Insert(r.Context(), tx, "Page", d) + if e != nil { + return e + } + if content, ok := d["content"].(string); ok { + if e := syncPageGraph(r.Context(), tx, d["id"].(string), user(r), content); e != nil { + return e + } + } + // Acknowledge the same snapshot the write produced (the update path's + // CR-18 discipline), so the 201 already carries links/embeds. + v, e = pageView(r.Context(), tx, d["id"].(string), user(r)) return e }) respond(w, 201, v, e) @@ -162,13 +199,25 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) { return conflict("Page was modified elsewhere; reload to see the latest version") } } + if raw, ok := d["parentId"]; ok { + parent, e := pageParent(r.Context(), tx, id, user(r), raw) + if e != nil { + return e + } + d["parentId"] = parent + } if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil { return e } - if _, ok := d["content"]; ok { + if content, ok := d["content"].(string); ok { if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil { return e } + // Rebuild resolved links/embeds in the same transaction as the + // Markdown they were derived from. + if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil { + return e + } } if bid := str(b, "addBookmarkId"); bid != "" { if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil { @@ -190,7 +239,22 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) { }) respond(w, 200, v, e) } + +// HandlePagesBacklinks returns the pages that link to this one. It is a +// separate read surface for navigation; the same owner-scoped list is +// embedded in pageView for the editor. +func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, e := owned(r, database(r), "Page", id, "ownerId"); e != nil { + respond(w, 0, nil, e) + return + } + v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=$1 AND s."ownerId"=$2 ORDER BY s."updatedAt" DESC,s.id`, id, user(r)) + respond(w, 200, v, e) +} func HandlePagesDelete(w http.ResponseWriter, r *http.Request) { + // Children are promoted to the top level by the self-FK's SET NULL; a + // delete never silently takes nested pages with it. respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId")) } func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/connected_pages_test.go b/internal/http/connected_pages_test.go new file mode 100644 index 0000000..fa1520d --- /dev/null +++ b/internal/http/connected_pages_test.go @@ -0,0 +1,161 @@ +package http + +import ( + "testing" +) + +// Connected pages (Notion-like upgrades phase 3): a real hierarchy with cycle +// rejection, Markdown-derived internal links/backlinks that never cross +// accounts, and live collection embeds that stay owner-scoped. + +func pageVersion(t *testing.T, c apiClient, id string) int { + t.Helper() + v := c.request("GET", "/api/pages/"+id, nil, 200) + version, _ := v["version"].(float64) + return int(version) +} + +func TestPageHierarchy(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "pages-tree@computer.test") + bob := newAPIUser(t, srv, "pages-tree-other@computer.test") + + root := alice.request("POST", "/api/pages", map[string]any{"title": "Root"}, 201)["id"].(string) + child := alice.request("POST", "/api/pages", map[string]any{"title": "Child", "parentId": root}, 201)["id"].(string) + grand := alice.request("POST", "/api/pages", map[string]any{"title": "Grandchild", "parentId": child}, 201)["id"].(string) + + listed := map[string]string{} + for _, p := range alice.getJSON("/api/pages", 200) { + parent, _ := p["parentId"].(string) + listed[p["id"].(string)] = parent + } + if listed[root] != "" || listed[child] != root || listed[grand] != child { + t.Fatalf("parentId not preserved in the list: %v", listed) + } + + // A page cannot parent itself, nor may a move create a cycle. + alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": child}, 400) + alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": grand}, 400) + alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": grand}, 400) + + // Foreign pages are not valid parents (and are not disclosed as existing). + foreign := bob.request("POST", "/api/pages", map[string]any{"title": "Bob only"}, 201)["id"].(string) + alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 400) + alice.request("POST", "/api/pages", map[string]any{"title": "Hijack", "parentId": foreign}, 400) + + // Moving a page between parents works and is metadata-only (no version + // requirement, matching title/pin changes). + moved := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": root}, 200) + if moved["parentId"] != root { + t.Fatalf("parent move not applied: %v", moved["parentId"]) + } + // Explicit null promotes to the top level. + promoted := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": nil}, 200) + if promoted["parentId"] != nil { + t.Fatalf("null parent did not promote page: %v", promoted["parentId"]) + } + alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": child}, 200) + + // Deleting a parent promotes its children instead of deleting them. + alice.request("DELETE", "/api/pages/"+child, nil, 200) + alice.request("GET", "/api/pages/"+child, nil, 404) + alice.request("GET", "/api/pages/"+grand, nil, 200) + if got := alice.request("GET", "/api/pages/"+grand, nil, 200)["parentId"]; got != nil { + t.Fatalf("orphaned child was not promoted after parent delete: %v", got) + } + + // A foreign user can never see or mutate the tree. + bob.request("GET", "/api/pages/"+root, nil, 404) + bob.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 404) +} + +func TestPageBacklinksAndEmbeds(t *testing.T) { + srv := testServer(t) + alice := newAPIUser(t, srv, "pages-graph@computer.test") + bob := newAPIUser(t, srv, "pages-graph-other@computer.test") + + target := alice.request("POST", "/api/pages", map[string]any{"title": "Target page"}, 201)["id"].(string) + bobTarget := bob.request("POST", "/api/pages", map[string]any{"title": "Private title"}, 201)["id"].(string) + mine := alice.request("POST", "/api/collections", map[string]any{"name": "Reading list"}, 201) + collection := mine["id"].(string) + view := mine["views"].([]any)[0].(map[string]any)["id"].(string) + foreignCollection := bob.request("POST", "/api/collections", map[string]any{"name": "Bob private"}, 201)["id"].(string) + + content := "# Connected\n\n" + + "- [Target](computer:page/" + target + ")\n" + + "- [Alias](/pages?id=" + target + ")\n" + + "- [Bob's page](computer:page/" + bobTarget + ")\n\n" + + "{{collection:" + collection + "|view=" + view + "}}\n" + + "{{collection:" + foreignCollection + "}}\n" + source := alice.request("POST", "/api/pages", map[string]any{"title": "Source page", "content": content}, 201)["id"].(string) + + // Duplicate aliases resolve to one edge; the foreign page is never stored. + tgt := alice.request("GET", "/api/pages/"+target, nil, 200) + backlinks := tgt["backlinks"].([]any) + if len(backlinks) != 1 || backlinks[0].(map[string]any)["id"] != source || backlinks[0].(map[string]any)["title"] != "Source page" { + t.Fatalf("backlinks did not resolve owner-scoped pages: %v", backlinks) + } + listed := alice.getJSON("/api/pages/"+target+"/backlinks", 200) + if len(listed) != 1 || listed[0]["id"] != source { + t.Fatalf("backlinks endpoint: %v", listed) + } + bob.request("GET", "/api/pages/"+target+"/backlinks", nil, 404) + + src := alice.request("GET", "/api/pages/"+source, nil, 200) + links := src["links"].([]any) + if len(links) != 1 || links[0].(map[string]any)["id"] != target { + t.Fatalf("outgoing links should contain only the owned target: %v", links) + } + embeds := src["embeds"].([]any) + if len(embeds) != 1 { + t.Fatalf("foreign collection embed was stored: %v", embeds) + } + embed := embeds[0].(map[string]any) + if embed["collectionId"] != collection || embed["viewId"] != view { + t.Fatalf("embed reference/view not preserved: %v", embed) + } + col := embed["collection"].(map[string]any) + if col["name"] != "Reading list" { + t.Fatalf("live embed is missing its collection projection: %v", col) + } + if props, ok := col["properties"].([]any); !ok || len(props) != 0 { + t.Fatalf("embed projection missing properties array: %v", col["properties"]) + } + + // Removing the Markdown and re-saving removes both derived rows, proving + // they are not append-only caches. + alice.request("PATCH", "/api/pages/"+source, map[string]any{ + "content": "# Disconnected", "version": pageVersion(t, alice, source), + }, 200) + if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 0 { + t.Fatalf("stale backlink survived a content save: %v", b) + } + src = alice.request("GET", "/api/pages/"+source, nil, 200) + if e := src["embeds"].([]any); len(e) != 0 { + t.Fatalf("stale embed survived a content save: %v", e) + } + if l := src["links"].([]any); len(l) != 0 { + t.Fatalf("stale outgoing link survived a content save: %v", l) + } + + // Re-linking re-establishes the edge; deleting the target removes it. + alice.request("PATCH", "/api/pages/"+source, map[string]any{ + "content": "[Target](computer:page/" + target + ")", "version": pageVersion(t, alice, source), + }, 200) + if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 1 { + t.Fatalf("re-link was not resolved: %v", b) + } + alice.request("DELETE", "/api/pages/"+target, nil, 200) + if l := alice.request("GET", "/api/pages/"+source, nil, 200)["links"].([]any); len(l) != 0 { + t.Fatalf("deleted target left a dangling link: %v", l) + } + + // Self-links are ignored (a page is not its own backlink). + selfContent := "[Me](computer:page/" + source + ")" + alice.request("PATCH", "/api/pages/"+source, map[string]any{ + "content": selfContent, "version": pageVersion(t, alice, source), + }, 200) + if b := alice.request("GET", "/api/pages/"+source, nil, 200)["backlinks"].([]any); len(b) != 0 { + t.Fatalf("self-link became a backlink: %v", b) + } +} diff --git a/internal/http/server.go b/internal/http/server.go index 29b1129..e524b71 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -88,6 +88,7 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server { mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet)) mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate)) mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete)) + mux.HandleFunc("GET /api/pages/{id}/backlinks", authed(handlers.HandlePagesBacklinks)) mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown)) // 4.7 Signals diff --git a/web/app/(app)/pages/client.tsx b/web/app/(app)/pages/client.tsx index 163c1c0..49a9769 100644 --- a/web/app/(app)/pages/client.tsx +++ b/web/app/(app)/pages/client.tsx @@ -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; 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 ( +
+
+ + {embed.collection.name} + {viewLabel && · {viewLabel}} + {items ? `${items.length} item${items.length === 1 ? '' : 's'}` : ''} +
+ {failed ? ( +

This collection could not be loaded.

+ ) : items === null ? ( +

Loading collection…

+ ) : items.length === 0 ? ( +

No items match this view.

+ ) : ( +
+ + + + + {properties.map((p) => )} + + + + {items.map((item) => ( + + + {properties.map((p) => )} + + ))} + +
Bookmark{p.name}
+ + {item.bookmark.title || item.bookmark.ogTitle || item.bookmark.url} + + {host(item.bookmark.url)} + {optionLabel(p, item.propertyValues?.[p.id])}
+
+ )} +
+ ) +} + 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([]) 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() + 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 (
@@ -540,6 +697,51 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
+
+ + { setPageLinkOpen(open); if (!open) setPageLinkQuery('') }}> + + + setPageLinkQuery(e.target.value)} placeholder="Find a page to link" className="h-8 text-sm" /> +
+ {pageLinkOptions.map((p) => ( + + ))} + {pageLinkOptions.length === 0 &&

No matching pages

} +
+
+
+ + + +
+ {collections.map((c) => ( + + ))} + {collections.length === 0 &&

No collections yet. Create one from Collections first.

} +
+
+
+
+ {saveState === 'conflict' && (

This page was changed elsewhere

@@ -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 ? ( + + ) : ( +

This embedded collection is unavailable or not yours.

+ ) + } const link = standaloneLink(node) return link ? ( @@ -635,7 +848,13 @@ export function PagesClient({ initialId }: { initialId: string | null }) {

) }, - a: ({ node, ...props }) => , + a: ({ href, ...props }) => { + const target = href ? internalPageId(href) : null + if (target) { + return + } + return + }, }} >{content} ) : ( @@ -692,6 +911,27 @@ export function PagesClient({ initialId }: { initialId: string | null }) { ))}

)} + + {page.backlinks.length > 0 && ( +
+

+ Linked from ({page.backlinks.length}) +

+
+ {page.backlinks.map((b) => ( + + ))} +
+
+ )} )} diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx index d25a65a..13c7f7c 100644 --- a/web/components/sidebar.tsx +++ b/web/components/sidebar.tsx @@ -151,6 +151,7 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null) + const [collapsedPages, setCollapsedPages] = useState>(() => new Set()) const draggedPageRef = useRef(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() + 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() + 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 (