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
+25 -5
View File
@@ -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/<pageId>)` links to another page. `/pages?id=<id>`
and `page/<id>` 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:<collectionId>}}` embeds the collection's live default view;
`{{collection:<collectionId>|view=<viewId>}}` 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
+3 -3
View File
@@ -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
+57
View File
@@ -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");
+35
View File
@@ -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:<id>|view=<id>}}`) 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;
+196
View File
@@ -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/<id>) links to another page. /pages?id=<id>
// and page/<id> are accepted as aliases so hand-written links work too.
// - {{collection:<id>}} embeds a live collection view; an optional
// {{collection:<id>|view=<viewId>}} 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+"[^"]*")?\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
}
+69 -5
View File
@@ -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) {
+161
View File
@@ -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)
}
}
+1
View File
@@ -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
+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.