From d1f0a51f37088aa2c4d1fa0d72d471d4b6139df8 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 5 Oct 2026 15:41:36 +0800 Subject: [PATCH] [feature] standalone docs get an "open in workspace" toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document opened on its own (a link from Discord, a bookmark) only had a faint "◉ pensieve" pill that led to the graph home, so getting to the same document inside the workspace shell meant finding it again by hand. The pill is now a two-part toolbar in the bottom-left corner: the workspace name goes to the workspace home, "在 workspace 中開啟" opens this document in the shell with the sidebar (the shell already deep-links through #/path). Bottom-left because every doc starts with a masthead in its top padding. It is built before the graph fetch so it no longer disappears when that request fails, follows the reader's language, and is hidden when printing. Public share links (/s/*) are untouched: they never inject the workspace nav. The nav script moved to lib/doc-nav.ts; values are serialised with < escaped so a workspace name containing cannot end the inline script. --- app/o/[slug]/d/[...path]/route.ts | 54 +++++------------------- lib/doc-nav.ts | 68 +++++++++++++++++++++++++++++++ lib/i18n-dict.tsx | 10 +++++ 3 files changed, 89 insertions(+), 43 deletions(-) create mode 100644 lib/doc-nav.ts diff --git a/app/o/[slug]/d/[...path]/route.ts b/app/o/[slug]/d/[...path]/route.ts index ec3abd9..d01faaf 100644 --- a/app/o/[slug]/d/[...path]/route.ts +++ b/app/o/[slug]/d/[...path]/route.ts @@ -2,8 +2,10 @@ import { and, eq } from "drizzle-orm"; import { db } from "@/lib/db"; import * as schema from "@/lib/schema"; import { accessUrl, requireMember } from "@/lib/access"; +import { getDict } from "@/lib/i18n"; +import { arg, navScript, type NavLabels } from "@/lib/doc-nav"; -// Serves a document's HTML with the pensieve nav (pill + backlinks) injected. +// Serves a document's HTML with the pensieve nav (toolbar + backlinks) injected. export async function GET(req: Request, ctx: { params: Promise<{ slug: string; path: string[] }> }) { const embed = new URL(req.url).searchParams.get("embed") === "1"; const { slug, path } = await ctx.params; @@ -28,51 +30,17 @@ export async function GET(req: Request, ctx: { params: Promise<{ slug: string; p eq(schema.document.organizationId, access.org.id), eq(schema.document.path, docPath))).limit(1); if (!rows.length) return new Response("not found", { status: 404 }); - const nav = ``; + const { t } = await getDict(); + const ui: NavLabels = { + workspace: access.org.name, + open: t.doc.openInWorkspace, + openHint: t.doc.openInWorkspaceHint, + homeHint: t.doc.workspaceHomeHint, + }; + const nav = ``; let html = rows[0].html; // in-workspace links resolve through the org prefix html = html.replaceAll(/href="(\/[^"#?]+?)(?:\.(?:html|md))?"/g, (_m, p) => `href="/o/${slug}/d${p}"`); html = html.includes("") ? html.replace("", nav + "") : html + nav; return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }); } - -function navScript(slug: string, here: string, embed: boolean) { - (async () => { - let g: { nodes: { id: string; title: string }[]; edges: { from: string; to: string }[] }; - try { g = await (await fetch(`/api/graph?org=${slug}`)).json(); } catch { return; } - const byId: Record = {}; - g.nodes.forEach((n) => (byId[n.id] = n)); - const outs = g.edges.filter((e) => e.from === here && byId[e.to]).map((e) => byId[e.to]); - const ins = g.edges.filter((e) => e.to === here && byId[e.from]).map((e) => byId[e.from]); - const st = document.createElement("style"); - st.textContent = ":root{scrollbar-width:thin} ::-webkit-scrollbar{width:8px;height:8px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:rgba(127,138,148,.5);border-radius:8px} .pnsv-pill{position:fixed;top:14px;left:14px;z-index:9999;font:11px/1 ui-monospace,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase;text-decoration:none;padding:7px 12px;border-radius:999px;background:rgba(20,26,27,.82);color:#F1F2F0;opacity:.55;transition:opacity .15s}.pnsv-pill:hover{opacity:1}@media (prefers-color-scheme:dark){.pnsv-pill{background:rgba(228,232,231,.14);color:#E4E8E7}}.pnsv-links{max-width:900px;margin:48px auto 0;padding:20px 24px 8px;border-top:1px solid rgba(127,138,137,.35);font-family:-apple-system,'PingFang TC',sans-serif}.pnsv-links h4{font:10.5px/1 ui-monospace,Menlo,monospace;letter-spacing:.14em;text-transform:uppercase;opacity:.55;margin:14px 0 8px}.pnsv-links a{display:inline-block;margin:0 10px 8px 0;font-size:13.5px;text-decoration:none;color:inherit;opacity:.8;border-bottom:1px solid rgba(127,138,137,.5)}.pnsv-links a:hover{opacity:1}"; - document.head.appendChild(st); - if (embed) { - document.addEventListener("click", (ev) => { - const t = ev.target as HTMLElement | null; - const a = t?.closest?.(`a[href^="/o/"]`) as HTMLAnchorElement | null; - // same-origin iframe: address the workspace shell explicitly, never "*" - if (a) { ev.preventDefault(); parent.postMessage({ pnsvOpen: a.getAttribute("href") }, location.origin); } - }); - // keystrokes inside the iframe never reach the workspace shell — forward ⌘K - document.addEventListener("keydown", (ev) => { - if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { - ev.preventDefault(); parent.postMessage({ pnsvPalette: true }, location.origin); - } - }); - } - if (!embed) { - const pill = document.createElement("a"); - pill.className = "pnsv-pill"; pill.href = `/o/${slug}`; pill.textContent = "◉ pensieve"; - document.body.appendChild(pill); - } - if (outs.length || ins.length) { - const box = document.createElement("div"); - box.className = "pnsv-links"; - const link = (n: { id: string; title: string }) => `${n.title}`; - box.innerHTML = (outs.length ? "

links to

" + outs.map(link).join("") : "") - + (ins.length ? "

linked from

" + ins.map(link).join("") : ""); - document.body.appendChild(box); - } - })(); -} diff --git a/lib/doc-nav.ts b/lib/doc-nav.ts new file mode 100644 index 0000000..705346c --- /dev/null +++ b/lib/doc-nav.ts @@ -0,0 +1,68 @@ +/** + * The script injected into a member's view of a document: the workspace + * toolbar when the page is opened on its own, backlinks, and the ⌘K / link + * forwarding when it is embedded in the workspace shell. Serialised with + * toString() into the document, so it must stay self-contained. + */ +export type NavLabels = { workspace: string; open: string; openHint: string; homeHint: string }; + +/** JSON for an inline " must not end it early. */ +export const arg = (v: unknown) => JSON.stringify(v).replaceAll("<", String.raw`\u003c`); + +export function navScript(slug: string, here: string, embed: boolean, ui: NavLabels) { + (async () => { + const st = document.createElement("style"); + st.textContent = ":root{scrollbar-width:thin} ::-webkit-scrollbar{width:8px;height:8px} ::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:rgba(127,138,148,.5);border-radius:8px} .pnsv-bar{position:fixed;bottom:16px;left:16px;z-index:9999;display:flex;align-items:stretch;max-width:calc(100vw - 32px);font:12.5px/1 -apple-system,BlinkMacSystemFont,'PingFang TC','Noto Sans TC',sans-serif;border-radius:999px;background:rgba(20,26,27,.88);color:#F1F2F0;box-shadow:0 2px 12px rgba(0,0,0,.18);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);overflow:hidden}.pnsv-bar a{color:inherit;text-decoration:none;display:flex;align-items:center;gap:8px;padding:8px 13px;min-width:0;transition:background .15s}.pnsv-bar a:hover{background:rgba(255,255,255,.12)}.pnsv-bar a:focus-visible{outline:2px solid #7FA8F0;outline-offset:-2px}.pnsv-bar .pnsv-dot{width:7px;height:7px;border-radius:50%;background:#7FA8F0;box-shadow:0 0 0 3px rgba(127,168,240,.22);flex:none}.pnsv-bar .pnsv-ws{font:11px/1 ui-monospace,Menlo,monospace;letter-spacing:.06em;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pnsv-bar .pnsv-sep{width:1px;background:rgba(255,255,255,.18);flex:none}.pnsv-bar svg{width:14px;height:14px;flex:none}@media (prefers-color-scheme:dark){.pnsv-bar{background:rgba(40,48,52,.9);color:#E4E8E7}}@media (max-width:560px){.pnsv-bar .pnsv-ws{display:none}}@media print{.pnsv-bar{display:none}} .pnsv-links{max-width:900px;margin:48px auto 0;padding:20px 24px 8px;border-top:1px solid rgba(127,138,137,.35);font-family:-apple-system,'PingFang TC',sans-serif}.pnsv-links h4{font:10.5px/1 ui-monospace,Menlo,monospace;letter-spacing:.14em;text-transform:uppercase;opacity:.55;margin:14px 0 8px}.pnsv-links a{display:inline-block;margin:0 10px 8px 0;font-size:13.5px;text-decoration:none;color:inherit;opacity:.8;border-bottom:1px solid rgba(127,138,137,.5)}.pnsv-links a:hover{opacity:1}"; + document.head.appendChild(st); + // Opened on its own (a shared link, a bookmark): give the reader a way into + // the workspace — the shell deep-links a doc through the #/path hash. Built + // before the graph fetch so it shows even when that request fails. Bottom-left + // because every doc starts with a masthead in the top padding. + if (!embed) { + const bar = document.createElement("nav"); + bar.className = "pnsv-bar"; + bar.setAttribute("aria-label", "Pensieve"); + const home = document.createElement("a"); + home.href = `/o/${slug}`; home.title = ui.homeHint; + const dot = document.createElement("span"); dot.className = "pnsv-dot"; + const ws = document.createElement("span"); ws.className = "pnsv-ws"; ws.textContent = ui.workspace; + home.append(dot, ws); + const sep = document.createElement("span"); sep.className = "pnsv-sep"; + const open = document.createElement("a"); + open.href = `/o/${slug}#${here}`; open.title = ui.openHint; + // lucide "panel-left", inline so the doc page needs nothing from the app bundle + open.innerHTML = ''; + open.append(document.createTextNode(ui.open)); + bar.append(home, sep, open); + document.body.appendChild(bar); + } + let g: { nodes: { id: string; title: string }[]; edges: { from: string; to: string }[] }; + try { g = await (await fetch(`/api/graph?org=${slug}`)).json(); } catch { return; } + const byId: Record = {}; + g.nodes.forEach((n) => (byId[n.id] = n)); + const outs = g.edges.filter((e) => e.from === here && byId[e.to]).map((e) => byId[e.to]); + const ins = g.edges.filter((e) => e.to === here && byId[e.from]).map((e) => byId[e.from]); + if (embed) { + document.addEventListener("click", (ev) => { + const t = ev.target as HTMLElement | null; + const a = t?.closest?.(`a[href^="/o/"]`) as HTMLAnchorElement | null; + // same-origin iframe: address the workspace shell explicitly, never "*" + if (a) { ev.preventDefault(); parent.postMessage({ pnsvOpen: a.getAttribute("href") }, location.origin); } + }); + // keystrokes inside the iframe never reach the workspace shell — forward ⌘K + document.addEventListener("keydown", (ev) => { + if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { + ev.preventDefault(); parent.postMessage({ pnsvPalette: true }, location.origin); + } + }); + } + if (outs.length || ins.length) { + const box = document.createElement("div"); + box.className = "pnsv-links"; + const link = (n: { id: string; title: string }) => `${n.title}`; + box.innerHTML = (outs.length ? "

links to

" + outs.map(link).join("") : "") + + (ins.length ? "

linked from

" + ins.map(link).join("") : ""); + document.body.appendChild(box); + } + })(); +} diff --git a/lib/i18n-dict.tsx b/lib/i18n-dict.tsx index d1770b2..dfd393c 100644 --- a/lib/i18n-dict.tsx +++ b/lib/i18n-dict.tsx @@ -30,6 +30,11 @@ const zh = { methods: { google: "使用 Google 登入", }, + doc: { + openInWorkspace: "在 workspace 中開啟", + openInWorkspaceHint: "帶著側欄與關聯圖瀏覽這份文件", + workspaceHomeHint: "回到 workspace 首頁", + }, home: { metaTitle: "登入 · Pensieve", ctx: "登入", @@ -113,6 +118,11 @@ const en: Dict = { methods: { google: "Sign in with Google", }, + doc: { + openInWorkspace: "Open in workspace", + openInWorkspaceHint: "Browse this document with the sidebar and graph", + workspaceHomeHint: "Back to the workspace home", + }, home: { metaTitle: "Sign in · Pensieve", ctx: "Sign in",