/** * The pages, rendered on the server: the first paint is the whole vault — * your notes on the left, the note in the middle, what it links to and * what links back on the right — and `static/app.js` makes it an editor * afterwards (autosave, live preview, the quick switcher, the keys). * Without JavaScript every page still reads, and the editor still saves. */ import { isAccount, type Account } from "./accounts.ts"; import { canDelete, type Note } from "./actions.ts"; import { commit, description, env, forgeUrl, publicOrigin, title, where } from "./env.ts"; import { asset, escape } from "./http.ts"; import { folderOf, spaceHref, type Member, type Space } from "./spaces.ts"; import { unsynced } from "./sync.ts"; import { token, tokensHref } from "./tokens.ts"; import { backlinks, excerpt, graph, headings, hrefOf, outgoing, renderNote, tagCounts, tags, type Hit, } from "./vault.ts"; import type { Who } from "./who.ts"; /** Data for the browser, inside a page: JSON that can never close its `; const CHANGES = "note.added note.removed note.changed space.changed space.synced"; /** * The frame every page shares: the tokens and the look, and what a link * to the page says when it is shared — a title, a sentence and the * address, for Open Graph and the card a chat app unfurls. */ export function layout( body: string, path = "/", options: { name?: string; kind?: string; scripts?: string[] } = {}, ): string { const name = options.name === undefined ? title : `${options.name} · ${title}`; return ` ${escape(name)} ${(options.scripts ?? []).map((s) => ``).join("\n")} ${body} `; } /** Signed out: what this is, and the ways in. */ export function landing(notice?: string, to = "/"): string { const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`; return layout( `

${escape(title)}

A private vault for your thinking. Markdown notes, linked with [[double brackets]], with backlinks, tags and a graph of how it all connects.

Have a forge account? Sign in with the forge.

Your notes are yours alone — nobody else who uses this app can read them.

${toast(notice)}
`, "/", { kind: "page landing-page" }, ); } /** Making an account, or logging in to one. */ export function authPage(input: { mode: "signup" | "login"; notice?: string | undefined; email?: string; name?: string; needsCode: boolean; /** Where to land afterwards: a join link, when that is what brought them. */ to?: string; }): string { const signup = input.mode === "signup"; const to = input.to ?? "/"; const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`; return layout( `
${to === "/" ? "" : ``} ${glyph}

${signup ? "Create your vault" : "Welcome back"}

${input.notice === undefined ? "" : ``} ${signup ? `` : ""} ${signup && input.needsCode ? `` : ""} ${signup ? `

At least 8 characters. There's no email reset, so keep it somewhere safe.

` : ""}

${signup ? `Already have one? Log in` : `New here? Create a vault`} · Forge sign-in

`, `/${input.mode}`, { name: signup ? "Create your vault" : "Log in", kind: "page landing-page" }, ); } const glyph = ``; const toast = (notice?: string): string => notice === undefined ? "" : `

${escape(notice)}

`; const icon = { plus: ``, today: ``, graph: ``, search: ``, left: ``, right: ``, pin: ``, trash: ``, theme: ``, user: ``, download: ``, folder: ``, people: ``, gear: ``, }; export interface Shell { readonly who: Who & { role: "user" | "admin" }; readonly vault: readonly Note[]; readonly active?: Note | undefined; readonly notice?: string | undefined; readonly query?: string; /** The shared folder this page is in; none for your own vault. */ readonly space?: Space | undefined; /** Every shared folder you are in, for the switcher. */ readonly spaces: readonly Space[]; } /** Where a vault's own pages are: `""` for yours, `/s/` for a shared folder. */ const baseOf = (s: Pick): string => (s.space === undefined ? "" : spaceHref(s.space.id)); /** What the vault is called in a page's crumbs. */ const vaultName = (s: Pick): string => (s.space === undefined ? "Vault" : s.space.name); /** The switcher: your vault, then every shared folder you are in. */ function vaults(s: Shell): string { if (s.spaces.length === 0 && s.who.role !== "admin") return ""; const row = (href: string, label: string, here: boolean, glyphs: string) => `
  • ${glyphs}${escape(label)}
  • `; return `

    Vaults

      ${row("/", "My notes", s.space === undefined, "")} ${s.spaces.map((sp) => row(spaceHref(sp.id), sp.name, s.space?.id === sp.id, icon.folder)).join("")} ${s.who.role === "admin" ? `
    • ${icon.plus}Share a repo folder
    • ` : ""}
    `; } /** A shared folder's notes as its folders hold them: a tree, folders first, A to Z. */ function tree(s: Shell, row: (n: Note) => string): string { interface Dir { dirs: Map; notes: Note[] } const root: Dir = { dirs: new Map(), notes: [] }; for (const n of s.vault) { let at = root; const folder = n.path === null || s.space === undefined ? "" : folderOf(s.space, n.path); for (const part of folder === "" ? [] : folder.split("/")) { if (!at.dirs.has(part)) at.dirs.set(part, { dirs: new Map(), notes: [] }); at = at.dirs.get(part)!; } at.notes.push(n); } const holds = (d: Dir): boolean => d.notes.some((n) => n.id === s.active?.id) || [...d.dirs.values()].some(holds); const draw = (d: Dir, path: string): string => `${[...d.dirs] .sort(([a], [b]) => a.localeCompare(b)) .map( ([name, sub]) => `
  • ${icon.folder}${escape(name)}
      ${draw(sub, `${path}${name}/`)}
  • `, ) .join("")}${[...d.notes].sort((a, b) => a.title.localeCompare(b.title)).map(row).join("")}`; return `
      ${draw(root, "")}
    `; } /** How a shared folder stands with its repository, for the foot of the sidebar. */ function syncLine(space: Space): string { const repo = forgeUrl === undefined ? escape(space.repo) : `${escape(space.repo)}`; const when = space.synced_at === null ? "not synced yet" : `synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}`; return `

    ${repo}/${escape(space.folder)} · ${space.sync_error === null ? when : "sync failed — see settings"}

    `; } /** The left: making notes, finding them, and every one of them. */ function left(s: Shell): string { const pinned = s.vault.filter((n) => n.pinned === 1); const rest = s.vault.filter((n) => n.pinned !== 1); const row = (n: Note) => `
  • ${escape(n.title)}
  • `; const tagList = tagCounts(s.vault); const base = baseOf(s); return ``; } /** The right: the note's shape and its neighbours. */ function right(s: Shell, note: Note): string { const vault = s.vault; const base = baseOf(s); const outline = headings(note.body); const back = backlinks(note, vault); const out = outgoing(note, vault); const mine = tags(note.body); const backTitle = note.title.toLowerCase(); const context = (n: Note): string => { const line = n.body .split("\n") .find((l) => l.toLowerCase().includes(`[[${backTitle}`)); return line === undefined ? "" : `${escape(excerpt(line, 110))}`; }; return ``; } /** Everything around a center: the sidebars, the toolbar's toggles, the switcher, the vault's titles for the browser. */ function shell(s: Shell, center: string, rightSide = ""): string { return `
    ${left(s)}
    ${center}
    ${rightSide}
    ${data("vault", s.vault.map((n) => ({ id: n.id, title: n.title, updated_at: n.updated_at })), CHANGES).replace(' id="vault"', ` id="vault" data-base="${baseOf(s)}"${s.space === undefined ? "" : ` data-space="${s.space.id}"`}`)} ${toast(s.notice)}`; } const toolbar = (s: Shell, crumbs: string, tools = ""): string => `
    ${crumbs}
    ${tools}
    ${s.active === undefined ? "" : ``}
    `; /** One note, open: the editor and the reading view side by side. */ export function notePage(s: Shell & { active: Note }): string { const note = s.active; const base = baseOf(s); const tools = `
    Saved
    ${icon.download} ${canDelete(s.who, note) ? `
    ` : ""}`; const folder = s.space === undefined || note.path === null ? "" : folderOf(s.space, note.path); const crumbs = [`${escape(vaultName(s))}`, ...(folder === "" ? [] : folder.split("/").map((f) => `${escape(f)}`)), `${escape(note.title)}`].join(``); const center = `${toolbar(s, crumbs, tools)}
    ${renderNote(note.body, s.vault, base) || `

    Nothing here yet.

    `}
    ${graphData(s.vault)}`; return layout(shell(s, center, right(s, note)), hrefOf(note), { name: note.title, kind: "page app-page", scripts: ["app.js", "graph.js"], }); } /** An empty vault: two ways to begin. */ export function emptyPage(s: Shell): string { if (s.space !== undefined) return emptySpace(s, s.space); const center = `${toolbar(s, `Vault`)}

    Your vault is empty

    Every note you write here is markdown, saved as you type, and private to you.

    `; return layout(shell(s, center), "/", { kind: "page app-page", scripts: ["app.js"] }); } /** A link to a note that isn't written yet. */ export function missingPage(s: Shell & { title: string }): string { const base = baseOf(s); const center = `${toolbar(s, `${escape(vaultName(s))}${escape(s.title)}`)}

    “${escape(s.title)}” doesn't exist yet

    A link points here, but nothing's been written.

    `; return layout(shell(s, center), `${base}/open`, { name: s.title, kind: "page app-page", scripts: ["app.js"] }); } /** What a search found. */ export function searchPage(s: Shell & { query: string; hits: readonly Hit[] }): string { const words = s.query .toLowerCase() .match(/"[^"]+"|\S+/g) ?.map((w) => w.replace(/^"|"$/g, "")) .filter((w) => !/^(#|tag:)/.test(w)) ?? []; const mark = (text: string): string => { let html = escape(text); for (const w of words) { const e = escape(w).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); if (e !== "") html = html.replace(new RegExp(`(${e})`, "gi"), "$1"); } return html; }; const center = `${toolbar(s, `${escape(vaultName(s))}Search`)}

    ${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for ${escape(s.query)}

    ${s.hits.length === 0 ? `

    Nothing matches. Try fewer words, or #tag.

    ` : ``}
    `; return layout(shell(s, center), `${baseOf(s)}/search`, { name: `Search: ${s.query}`, kind: "page app-page", scripts: ["app.js"] }); } /** The whole vault, as a graph. */ export function graphPage(s: Shell): string { const g = graph(s.vault); if (g.nodes.length === 0) return emptyPage(s); const center = `${toolbar(s, `${escape(vaultName(s))}Graph`, `${g.nodes.length} notes · ${g.edges.length} links`)}

    Drag to move · scroll or pinch to zoom · click a note to open it

    ${graphData(s.vault)}`; return layout(shell(s, center), `${baseOf(s)}/graph`, { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] }); } /** An email account: who you are, a new password, or leaving. */ export function accountPage(s: Shell & { account: Account }): string { const a = s.account; const center = `${toolbar(s, `VaultAccount`)}

    ${escape(a.name)}

    ${escape(a.email)} · ${s.vault.length} ${s.vault.length === 1 ? "note" : "notes"} · since ${long(a.created_at)}

    Change password

    Logs you out everywhere else.

    Delete account

    Your account and every note in your vault, gone for good. Download everything first.

    `; return layout(shell(s, center), "/account", { name: "Account", kind: "page app-page", scripts: ["app.js"] }); } /** A shared note's file: where it is, who wrote last, whether the repository has it yet, and moving it. */ function filepanel(space: Space, note: Note & { path: string }): string { return `

    File

    ${escape(note.path)}

    Last written by ${escape(note.author_name)} · ${unsynced(note) ? "waiting to commit" : "committed"}

    `; } /** A shared folder with nothing in it yet. */ function emptySpace(s: Shell, space: Space): string { const base = spaceHref(space.id); const center = `${toolbar(s, `${escape(space.name)}`)}

    ${escape(space.name)} is empty

    Notes written here are committed to ${escape(space.repo)} in ${escape(space.folder || "/")} — and markdown pushed there shows up here.

    ${space.sync_error === null ? "" : ``}
    Invite someone
    `; return layout(shell(s, center), base, { name: space.name, kind: "page app-page", scripts: ["app.js"] }); } /** A shared folder's settings: who is in, the join link, and the repository. */ export function spacePage(s: Shell & { space: Space; members: readonly Member[] }): string { const space = s.space; const base = spaceHref(space.id); const mine = space.owner_id === s.who.id; const link = space.join_code === null ? null : `${publicOrigin}/join/${space.join_code}`; const center = `${toolbar(s, `${escape(space.name)}Sharing`)}

    ${escape(space.name)}

    ${escape(space.repo)} · branch ${escape(space.branch)} · folder ${escape(space.folder || "/")}

    Repository

    ${space.sync_error === null ? `

    ${space.synced_at === null ? "Not synced yet." : `Synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}${space.head === null ? "" : ` at ${escape(space.head.slice(0, 7))}`}.`} Edits commit a few seconds after you stop typing; pushes to the repository show up within a minute.

    ` : ``}

    People ${s.members.length}

      ${s.members .map( (m) => `
    • ${escape(m.who_name)}${m.who_id === space.owner_id ? ` owner` : ""}${m.who_id === s.who.id ? ` (you)` : ""}${ mine && m.who_id !== space.owner_id ? `
      ` : "" }
    • `, ) .join("")}
    ${ mine ? `

    Join link

    ${ link === null ? `

    The link is off: nobody new can join.

    ` : `

    Anyone signed in to notes who opens this joins ${escape(space.name)} — and sees only this folder, never your own notes. Send it to who you're working with, then turn it off.

    ` }

    Stop sharing

    Removes the folder from notes for everyone. Nothing in the repository is touched.

    ` : `

    Leave

    You won't see this folder any more, until someone sends you a join link again.

    ` }
    `; return layout(shell(s, center), `${base}/settings`, { name: `${space.name} · Sharing`, kind: "page app-page", scripts: ["app.js"] }); } /** Connecting a repository folder: an admin's form. */ export function newSpacePage(s: Shell & { form?: { name: string; repo: string; branch: string; folder: string } }): string { const f = s.form ?? { name: "", repo: "", branch: "main", folder: "docs" }; const center = `${toolbar(s, `VaultShare a repo folder`)}

    Share a repo folder

    A folder of a repository on the forge becomes a vault you write in with others. Every note is a markdown file there; edits here are commits, and pushes there show up here. Use a plain repository, not an app's — every save is a commit to the branch.

    `; return layout(shell(s, center), "/spaces/new", { name: "Share a repo folder", kind: "page app-page", scripts: ["app.js"] }); } /** The graph's data, for any page that draws one. */ export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES); const words = (body: string): number => body.split(/\s+/).filter((w) => /\w/.test(w)).length; const long = (iso: string): string => new Date(iso).toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" }); export const ago = (iso: string): string => { const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); if (s < 60) return "now"; if (s < 3600) return `${Math.floor(s / 60)}m`; if (s < 86400) return `${Math.floor(s / 3600)}h`; if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`; return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" }); };