/** * 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, publicOrigin, title, where } from "./env.ts"; import { asset, escape } from "./http.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"; /** * 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): string { 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; }): string { const signup = input.mode === "signup"; return layout( `
${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: ``, }; interface Shell { readonly who: Who & { role: "user" | "admin" }; readonly vault: readonly Note[]; readonly active?: Note | undefined; readonly notice?: string | undefined; readonly query?: string; } /** 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); return ``; } /** The right: the note's shape and its neighbours. */ function right(note: Note, vault: readonly Note[]): string { 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)} ${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 tools = `
    Saved
    ${icon.download} ${canDelete(s.who, note) ? `
    ` : ""}`; const center = `${toolbar(s, `Vault${escape(note.title)}`, tools)}
    ${renderNote(note.body, s.vault) || `

    Nothing here yet.

    `}
    ${graphData(s.vault)}`; return layout(shell(s, center, right(note, s.vault)), 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 { 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 center = `${toolbar(s, `Vault${escape(s.title)}`)}

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

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

    `; return layout(shell(s, center), "/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, `VaultSearch`)}

    ${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), "/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, `VaultGraph`, `${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), "/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"] }); } /** 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" }); };