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",