benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 48459319b3fcac902928ea7f5ec007a0d1a287bc
579 lines · 37.0 KB · ~11k tok Raw History
1
/**
2
 * The pages, rendered on the server: the first paint is the whole vault —
3
 * your notes on the left, the note in the middle, what it links to and
4
 * what links back on the right — and `static/app.js` makes it an editor
5
 * afterwards (autosave, live preview, the quick switcher, the keys).
6
 * Without JavaScript every page still reads, and the editor still saves.
7
 */
8
9
import { isAccount, type Account } from "./accounts.ts";
10
import { canDelete, type Note } from "./actions.ts";
11
import { commit, description, env, forgeUrl, publicOrigin, title, where } from "./env.ts";
12
import { asset, escape } from "./http.ts";
13
import { folderOf, spaceHref, type Member, type Space } from "./spaces.ts";
14
import { unsynced } from "./sync.ts";
15
import { token, tokensHref } from "./tokens.ts";
16
import {
17
  backlinks,
18
  excerpt,
19
  graph,
20
  headings,
21
  hrefOf,
22
  outgoing,
23
  renderNote,
24
  tagCounts,
25
  tags,
26
  type Hit,
27
} from "./vault.ts";
28
import type { Who } from "./who.ts";
29
30
/** Data for the browser, inside a page: JSON that can never close its <script>. */
31
const data = (id: string, value: unknown, live = ""): string =>
32
  `<script type="application/json" id="${id}"${live ? ` data-live="${live}"` : ""}>${JSON.stringify(value).replace(/</g, "\\u003c")}</script>`;
33
34
const CHANGES = "note.added note.removed note.changed space.changed space.synced";
35
36
/**
37
 * The frame every page shares: the tokens and the look, and what a link
38
 * to the page says when it is shared — a title, a sentence and the
39
 * address, for Open Graph and the card a chat app unfurls.
40
 */
41
export function layout(
42
  body: string,
43
  path = "/",
44
  options: { name?: string; kind?: string; scripts?: string[] } = {},
45
): string {
46
  const name = options.name === undefined ? title : `${options.name} · ${title}`;
47
  return `<!doctype html>
48
<html lang="en">
49
<meta charset="utf-8">
50
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
51
<meta name="color-scheme" content="light dark">
52
<meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
53
<meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
54
<link rel="icon" href="data:,">
55
<title>${escape(name)}</title>
56
<meta name="description" content="${escape(description)}">
57
<meta property="og:type" content="website">
58
<meta property="og:site_name" content="${escape(title)}">
59
<meta property="og:title" content="${escape(title)}">
60
<meta property="og:description" content="${escape(description)}">
61
<meta property="og:url" content="${escape(publicOrigin + path)}">
62
<meta name="twitter:card" content="summary">
63
<meta name="twitter:title" content="${escape(title)}">
64
<meta name="twitter:description" content="${escape(description)}">
65
<script src="${asset("theme.js")}"></script>
66
<link rel="stylesheet" href="${tokensHref}">
67
<link rel="stylesheet" href="${asset("app.css")}">
68
<script type="module" src="${asset("live.js")}"></script>
69
${(options.scripts ?? []).map((s) => `<script type="module" src="${asset(s)}"></script>`).join("\n")}
70
<body class="${escape(options.kind ?? "page")}">
71
${body}
72
</body>
73
</html>
74
`;
75
}
76
77
/** Signed out: what this is, and the ways in. */
78
export function landing(notice?: string, to = "/"): string {
79
  const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`;
80
  return layout(
81
    `<div class="landing">
82
  <div class="landing-card">
83
    <div class="mark" aria-hidden="true">${glyph}</div>
84
    <h1>${escape(title)}</h1>
85
    <p class="lede">A private vault for your thinking. Markdown notes, linked with <code>[[double brackets]]</code>, with backlinks, tags and a graph of how it all connects.</p>
86
    <div class="row center">
87
      <a class="button button-big" href="/signup${next}">Create your vault</a>
88
      <a class="button button-big button-quiet" href="/login${next}">Log in</a>
89
    </div>
90
    <p class="muted small">Have a forge account? <a href="/__forge/signin?to=${escape(to)}">Sign in with the forge</a>.</p>
91
    <p class="muted small">Your notes are yours alone — nobody else who uses this app can read them.</p>
92
  </div>
93
  ${toast(notice)}
94
</div>`,
95
    "/",
96
    { kind: "page landing-page" },
97
  );
98
}
99
100
/** Making an account, or logging in to one. */
101
export function authPage(input: {
102
  mode: "signup" | "login";
103
  notice?: string | undefined;
104
  email?: string;
105
  name?: string;
106
  needsCode: boolean;
107
  /** Where to land afterwards: a join link, when that is what brought them. */
108
  to?: string;
109
}): string {
110
  const signup = input.mode === "signup";
111
  const to = input.to ?? "/";
112
  const next = to === "/" ? "" : `?to=${encodeURIComponent(to)}`;
113
  return layout(
114
    `<div class="landing">
115
  <form class="landing-card auth" method="post" action="/${input.mode}">
116
    ${to === "/" ? "" : `<input type="hidden" name="to" value="${escape(to)}">`}
117
    <a class="mark" href="/" aria-label="Back">${glyph}</a>
118
    <h1>${signup ? "Create your vault" : "Welcome back"}</h1>
119
    ${input.notice === undefined ? "" : `<p class="auth-error" role="alert">${escape(input.notice)}</p>`}
120
    ${signup ? `<label class="field"><span>Name <span class="muted">(optional)</span></span><input class="input" name="name" value="${escape(input.name ?? "")}" maxlength="60" autocomplete="name"></label>` : ""}
121
    <label class="field"><span>Email</span><input class="input" type="email" name="email" value="${escape(input.email ?? "")}" required maxlength="320" autocomplete="email" autofocus></label>
122
    <label class="field"><span>Password</span><input class="input" type="password" name="password" required minlength="${signup ? 8 : 1}" maxlength="200" autocomplete="${signup ? "new-password" : "current-password"}"></label>
123
    ${signup && input.needsCode ? `<label class="field"><span>Sign-up code</span><input class="input" name="code" required autocomplete="off"></label>` : ""}
124
    ${signup ? `<p class="muted small">At least 8 characters. There's no email reset, so keep it somewhere safe.</p>` : ""}
125
    <button class="button button-big button-wide">${signup ? "Create account" : "Log in"}</button>
126
    <p class="muted small">${signup ? `Already have one? <a href="/login${next}">Log in</a>` : `New here? <a href="/signup${next}">Create a vault</a>`} · <a href="/__forge/signin?to=${escape(to)}">Forge sign-in</a></p>
127
  </form>
128
</div>`,
129
    `/${input.mode}`,
130
    { name: signup ? "Create your vault" : "Log in", kind: "page landing-page" },
131
  );
132
}
133
134
const glyph = `<svg viewBox="0 0 32 32" width="40" height="40"><path d="M16 2 28 9v14l-12 7L4 23V9z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M16 9v16M10 12.5l12 7M22 12.5l-12 7" stroke="currentColor" stroke-width="1.5" opacity=".55"/><circle cx="16" cy="16" r="2.6" fill="currentColor"/></svg>`;
135
136
const toast = (notice?: string): string =>
137
  notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`;
138
139
const icon = {
140
  plus: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`,
141
  today: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>`,
142
  graph: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="7" r="2.2"/><circle cx="18" cy="6" r="2.2"/><circle cx="12" cy="17" r="2.2"/><path d="M8 8l3 7M16 7.6l-3 7.5M8.2 7h7.6"/></svg>`,
143
  search: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/></svg>`,
144
  left: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>`,
145
  right: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg>`,
146
  pin: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4h6l-1 6 4 3v2H6v-2l4-3zM12 15v6"/></svg>`,
147
  trash: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>`,
148
  theme: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 4a8 8 0 0 0 0 16z" fill="currentColor"/></svg>`,
149
  user: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6"/></svg>`,
150
  download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>`,
151
  folder: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`,
152
  people: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3.2"/><circle cx="17" cy="9" r="2.6"/><path d="M3 20c1-3.5 3.4-5 6-5s5 1.5 6 5M15 15.3c2.6-.4 4.8 1 5.6 3.7"/></svg>`,
153
  gear: `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></svg>`,
154
};
155
156
export interface Shell {
157
  readonly who: Who & { role: "user" | "admin" };
158
  readonly vault: readonly Note[];
159
  readonly active?: Note | undefined;
160
  readonly notice?: string | undefined;
161
  readonly query?: string;
162
  /** The shared folder this page is in; none for your own vault. */
163
  readonly space?: Space | undefined;
164
  /** Every shared folder you are in, for the switcher. */
165
  readonly spaces: readonly Space[];
166
}
167
168
/** Where a vault's own pages are: `""` for yours, `/s/<id>` for a shared folder. */
169
const baseOf = (s: Pick<Shell, "space">): string => (s.space === undefined ? "" : spaceHref(s.space.id));
170
/** What the vault is called in a page's crumbs. */
171
const vaultName = (s: Pick<Shell, "space">): string => (s.space === undefined ? "Vault" : s.space.name);
172
173
/** The switcher: your vault, then every shared folder you are in. */
174
function vaults(s: Shell): string {
175
  if (s.spaces.length === 0 && s.who.role !== "admin") return "";
176
  const row = (href: string, label: string, here: boolean, glyphs: string) =>
177
    `<li><a class="file${here ? " is-active" : ""}" href="${href}"${here ? ' aria-current="true"' : ""}><span class="file-title">${glyphs}${escape(label)}</span></a></li>`;
178
  return `<p class="section">Vaults</p><ul class="file-list vaults">
179
    ${row("/", "My notes", s.space === undefined, "")}
180
    ${s.spaces.map((sp) => row(spaceHref(sp.id), sp.name, s.space?.id === sp.id, icon.folder)).join("")}
181
    ${s.who.role === "admin" ? `<li><a class="file muted" href="/spaces/new"><span class="file-title">${icon.plus}Share a repo folder</span></a></li>` : ""}
182
  </ul>`;
183
}
184
185
/** A shared folder's notes as its folders hold them: a tree, folders first, A to Z. */
186
function tree(s: Shell, row: (n: Note) => string): string {
187
  interface Dir { dirs: Map<string, Dir>; notes: Note[] }
188
  const root: Dir = { dirs: new Map(), notes: [] };
189
  for (const n of s.vault) {
190
    let at = root;
191
    const folder = n.path === null || s.space === undefined ? "" : folderOf(s.space, n.path);
192
    for (const part of folder === "" ? [] : folder.split("/")) {
193
      if (!at.dirs.has(part)) at.dirs.set(part, { dirs: new Map(), notes: [] });
194
      at = at.dirs.get(part)!;
195
    }
196
    at.notes.push(n);
197
  }
198
  const holds = (d: Dir): boolean => d.notes.some((n) => n.id === s.active?.id) || [...d.dirs.values()].some(holds);
199
  const draw = (d: Dir, path: string): string =>
200
    `${[...d.dirs]
201
      .sort(([a], [b]) => a.localeCompare(b))
202
      .map(
203
        ([name, sub]) =>
204
          `<li><details class="folder" data-folder="${escape(path + name)}"${holds(sub) || sub.dirs.size + sub.notes.length < 12 ? " open" : ""}><summary>${icon.folder}<span>${escape(name)}</span></summary><ul class="file-list">${draw(sub, `${path}${name}/`)}</ul></details></li>`,
205
      )
206
      .join("")}${[...d.notes].sort((a, b) => a.title.localeCompare(b.title)).map(row).join("")}`;
207
  return `<ul class="file-list">${draw(root, "")}</ul>`;
208
}
209
210
/** How a shared folder stands with its repository, for the foot of the sidebar. */
211
function syncLine(space: Space): string {
212
  const repo = forgeUrl === undefined ? escape(space.repo) : `<a href="${escape(`${forgeUrl.replace(/\/+$/, "")}/${space.repo}`)}" target="_blank" rel="noopener noreferrer">${escape(space.repo)}</a>`;
213
  const when = space.synced_at === null ? "not synced yet" : `synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}`;
214
  return `<p class="sync-line small${space.sync_error === null ? " muted" : " is-error"}" title="${escape(space.sync_error ?? `${space.branch}:${space.folder || "/"}`)}">${repo}/${escape(space.folder)} · ${space.sync_error === null ? when : "sync failed — see settings"}</p>`;
215
}
216
217
/** The left: making notes, finding them, and every one of them. */
218
function left(s: Shell): string {
219
  const pinned = s.vault.filter((n) => n.pinned === 1);
220
  const rest = s.vault.filter((n) => n.pinned !== 1);
221
  const row = (n: Note) =>
222
    `<li><a class="file${s.active?.id === n.id ? " is-active" : ""}" href="${hrefOf(n)}"${s.active?.id === n.id ? ' aria-current="page"' : ""} data-title="${escape(n.title.toLowerCase())}"><span class="file-title">${escape(n.title)}</span><time class="file-when" datetime="${escape(n.updated_at)}">${ago(n.updated_at)}</time></a></li>`;
223
  const tagList = tagCounts(s.vault);
224
  const base = baseOf(s);
225
  return `<aside class="left" id="left" aria-label="${s.space === undefined ? "Your notes" : escape(s.space.name)}">
226
  <div class="left-head">
227
    <a class="brand" href="/">${glyph}<span>${escape(title)}</span></a>
228
    <button type="button" class="icon-button" data-action="theme" aria-label="Switch theme" title="Theme">${icon.theme}</button>
229
  </div>
230
  <div class="actions">
231
    <form method="post" action="${base}/new">${s.space !== undefined && s.active?.path ? `<input type="hidden" name="folder" value="${escape(folderOf(s.space, s.active.path))}">` : ""}<button class="button button-wide" title="New note (Alt N)">${icon.plus}<span>New note</span></button></form>
232
    <form method="post" action="${base}/daily" data-daily><input type="hidden" name="tz" value="0"><button class="icon-button" aria-label="Today's note" title="Today's note (⌘⇧D)">${icon.today}</button></form>
233
    <a class="icon-button" href="${base}/graph" aria-label="Graph" title="Graph (⌘G)">${icon.graph}</a>
234
    ${s.space === undefined ? "" : `<a class="icon-button" href="${base}/settings" aria-label="Sharing and sync" title="Sharing and sync">${icon.people}</a>`}
235
  </div>
236
  <form class="find" method="get" action="${base}/search" role="search">
237
    ${icon.search}
238
    <input class="input input-find" type="search" name="q" value="${escape(s.query ?? "")}" placeholder="Search or filter…" aria-label="Search notes" autocomplete="off" data-filter>
239
    <kbd class="hint">⌘K</kbd>
240
  </form>
241
  <nav class="files" id="files" data-live="${CHANGES}">
242
    ${vaults(s)}
243
    ${s.vault.length === 0 ? `<p class="muted small pad">No notes yet.</p>` : ""}
244
    ${pinned.length > 0 ? `<p class="section">Pinned</p><ul class="file-list">${pinned.map(row).join("")}</ul>` : ""}
245
    ${
246
      s.space !== undefined
247
        ? s.vault.length > 0
248
          ? `<p class="section">Files <span class="count">${s.vault.length}</span></p>${tree(s, row)}`
249
          : ""
250
        : rest.length > 0
251
          ? `<p class="section">${pinned.length > 0 ? "Notes" : `Notes <span class="count">${rest.length}</span>`}</p><ul class="file-list">${rest.map(row).join("")}</ul>`
252
          : ""
253
    }
254
    ${tagList.length > 0 ? `<p class="section">Tags</p><div class="tag-cloud">${tagList.map((t) => `<a class="tag" href="${base}/search?q=${encodeURIComponent(`#${t.tag}`)}">#${escape(t.tag)}<span class="count">${t.count}</span></a>`).join("")}</div>` : ""}
255
    ${s.space === undefined ? "" : syncLine(s.space)}
256
  </nav>
257
  <div class="left-foot">
258
    <span class="you" title="${escape(where)}${commit ? ` · ${escape(commit)}` : ""}">${escape(s.who.name)}${env === "production" ? "" : ` · <span class="pill">${escape(where)}</span>`}</span>
259
    ${s.space === undefined ? `<a class="icon-button" href="/export" aria-label="Download every note" title="Download everything">${icon.download}</a>` : ""}
260
    ${
261
      isAccount(s.who)
262
        ? `<a class="icon-button" href="/account" aria-label="Your account" title="Account">${icon.user}</a><form method="post" action="/logout"><button class="button button-quiet">Log out</button></form>`
263
        : `<a class="button button-quiet" href="/__forge/signout">Sign out</a>`
264
    }
265
  </div>
266
</aside>`;
267
}
268
269
/** The right: the note's shape and its neighbours. */
270
function right(s: Shell, note: Note): string {
271
  const vault = s.vault;
272
  const base = baseOf(s);
273
  const outline = headings(note.body);
274
  const back = backlinks(note, vault);
275
  const out = outgoing(note, vault);
276
  const mine = tags(note.body);
277
  const backTitle = note.title.toLowerCase();
278
  const context = (n: Note): string => {
279
    const line = n.body
280
      .split("\n")
281
      .find((l) => l.toLowerCase().includes(`[[${backTitle}`));
282
    return line === undefined ? "" : `<span class="context">${escape(excerpt(line, 110))}</span>`;
283
  };
284
  return `<aside class="right" id="context" aria-label="About this note" data-live="${CHANGES}">
285
  <section class="panel">
286
    <h2 class="section">Graph</h2>
287
    <canvas class="local-graph" data-graph="local" data-focus="${note.id}" aria-label="This note and its neighbours"></canvas>
288
  </section>
289
  <section class="panel">
290
    <h2 class="section">Backlinks <span class="count">${back.length}</span></h2>
291
    ${back.length === 0 ? `<p class="muted small">Nothing links here yet. Write <code>[[${escape(note.title)}]]</code> in another note.</p>` : `<ul class="link-list">${back.map((n) => `<li><a href="${hrefOf(n)}">${escape(n.title)}</a>${context(n)}</li>`).join("")}</ul>`}
292
  </section>
293
  <section class="panel">
294
    <h2 class="section">Links <span class="count">${out.found.length + out.missing.length}</span></h2>
295
    ${out.found.length + out.missing.length === 0 ? `<p class="muted small">No links out.</p>` : `<ul class="link-list">${out.found.map((n) => `<li><a href="${hrefOf(n)}">${escape(n.title)}</a></li>`).join("")}${out.missing.map((t) => `<li><a class="unresolved" href="${base}/open?title=${encodeURIComponent(t)}" data-title="${escape(t)}">${escape(t)}</a></li>`).join("")}</ul>`}
296
  </section>
297
  ${outline.length > 0 ? `<section class="panel"><h2 class="section">Outline</h2><ol class="outline">${outline.map((h) => `<li class="depth-${Math.min(h.level - 1, 5)}"><a href="#${escape(h.id)}">${escape(h.text)}</a></li>`).join("")}</ol></section>` : ""}
298
  ${mine.length > 0 ? `<section class="panel"><h2 class="section">Tags</h2><div class="tag-cloud">${mine.map((t) => `<a class="tag" href="${base}/search?q=${encodeURIComponent(`#${t}`)}">#${escape(t)}</a>`).join("")}</div></section>` : ""}
299
  ${s.space === undefined || note.path === null ? "" : filepanel(s.space, note as Note & { path: string })}
300
  <section class="panel meta muted small">
301
    <p>Created <time datetime="${escape(note.created_at)}">${long(note.created_at)}</time></p>
302
    <p>${words(note.body)} words · ${note.body.length.toLocaleString("en")} characters</p>
303
  </section>
304
</aside>`;
305
}
306
307
/** Everything around a center: the sidebars, the toolbar's toggles, the switcher, the vault's titles for the browser. */
308
function shell(s: Shell, center: string, rightSide = ""): string {
309
  return `<div class="shell" data-has-right="${rightSide === "" ? "false" : "true"}">
310
  ${left(s)}
311
  <div class="scrim" data-action="close-panels" hidden></div>
312
  <main class="center" id="main">${center}</main>
313
  ${rightSide}
314
</div>
315
<div class="palette" id="palette" hidden>
316
  <div class="palette-box" role="dialog" aria-modal="true" aria-label="Go to a note">
317
    <input class="input palette-input" id="palette-input" placeholder="Find or create a note…" aria-label="Note title" autocomplete="off" role="combobox" aria-expanded="true" aria-controls="palette-list">
318
    <ul class="palette-list" id="palette-list" role="listbox"></ul>
319
    <p class="palette-help muted small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>↵</kbd> open · <kbd>⇧↵</kbd> create · <kbd>esc</kbd> close</p>
320
  </div>
321
</div>
322
<ul class="suggest" id="suggest" role="listbox" hidden></ul>
323
${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}"`}`)}
324
${toast(s.notice)}`;
325
}
326
327
const toolbar = (s: Shell, crumbs: string, tools = ""): string => `<header class="toolbar">
328
  <button type="button" class="icon-button only-narrow" data-action="toggle-left" aria-label="Show notes">${icon.left}</button>
329
  <div class="crumbs">${crumbs}</div>
330
  <div class="tools">${tools}</div>
331
  ${s.active === undefined ? "" : `<button type="button" class="icon-button only-narrow-right" data-action="toggle-right" aria-label="Show links">${icon.right}</button>`}
332
</header>`;
333
334
/** One note, open: the editor and the reading view side by side. */
335
export function notePage(s: Shell & { active: Note }): string {
336
  const note = s.active;
337
  const base = baseOf(s);
338
  const tools = `<div class="segmented" role="group" aria-label="View">
339
      <button type="button" data-mode="edit" aria-pressed="false" title="Edit">Edit</button>
340
      <button type="button" data-mode="split" aria-pressed="false" title="Edit and read side by side" class="only-wide">Split</button>
341
      <button type="button" data-mode="read" aria-pressed="false" title="Read (⌘E)">Read</button>
342
    </div>
343
    <span class="status muted small" id="status" aria-live="polite">Saved</span>
344
    <form method="post" action="/n/${note.id}/pin"><input type="hidden" name="pinned" value="${note.pinned === 1 ? "0" : "1"}"><button class="icon-button${note.pinned === 1 ? " is-on" : ""}" aria-label="${note.pinned === 1 ? "Unpin" : "Pin"}" title="${note.pinned === 1 ? "Unpin" : "Pin to the top"}" aria-pressed="${note.pinned === 1}">${icon.pin}</button></form>
345
    <a class="icon-button" href="/n/${note.id}/raw" aria-label="Download as markdown" title="Download .md">${icon.download}</a>
346
    ${canDelete(s.who, note) ? `<form method="post" action="/n/${note.id}/delete" data-confirm="Delete “${escape(note.title)}”? This cannot be undone."><button class="icon-button danger" aria-label="Delete note" title="Delete">${icon.trash}</button></form>` : ""}`;
347
  const folder = s.space === undefined || note.path === null ? "" : folderOf(s.space, note.path);
348
  const crumbs = [`<a href="${base || "/"}">${escape(vaultName(s))}</a>`, ...(folder === "" ? [] : folder.split("/").map((f) => `<span>${escape(f)}</span>`)), `<span class="crumb-here">${escape(note.title)}</span>`].join(`<span aria-hidden="true">/</span>`);
349
  const center = `${toolbar(s, crumbs, tools)}
350
  <form class="editor" id="editor" method="post" action="/n/${note.id}" data-id="${note.id}" data-updated="${escape(note.updated_at)}">
351
    <div class="sheet">
352
      <input class="title-input" id="title" name="title" value="${escape(note.title)}" aria-label="Title" maxlength="200" autocomplete="off" spellcheck="false">
353
      <div class="panes" id="panes" data-mode="${note.body.trim() === "" ? "edit" : "split"}">
354
        <textarea class="source" id="source" name="body" aria-label="Note, in markdown" placeholder="Start writing. [[Link]] other notes, #tag this one…" spellcheck="true">${escape(note.body)}</textarea>
355
        <article class="md preview" id="preview" aria-label="Reading view">${renderNote(note.body, s.vault, base) || `<p class="muted">Nothing here yet.</p>`}</article>
356
      </div>
357
      <noscript><p class="row"><button class="button">Save</button></p></noscript>
358
    </div>
359
  </form>
360
  ${graphData(s.vault)}`;
361
  return layout(shell(s, center, right(s, note)), hrefOf(note), {
362
    name: note.title,
363
    kind: "page app-page",
364
    scripts: ["app.js", "graph.js"],
365
  });
366
}
367
368
/** An empty vault: two ways to begin. */
369
export function emptyPage(s: Shell): string {
370
  if (s.space !== undefined) return emptySpace(s, s.space);
371
  const center = `${toolbar(s, `<span class="crumb-here">Vault</span>`)}
372
  <div class="empty-state">
373
    <div class="mark" aria-hidden="true">${glyph}</div>
374
    <h1>Your vault is empty</h1>
375
    <p class="muted">Every note you write here is markdown, saved as you type, and private to you.</p>
376
    <div class="row center">
377
      <form method="post" action="/welcome"><button class="button button-big">Start with a welcome note</button></form>
378
      <form method="post" action="/new"><button class="button button-big button-quiet">Blank note</button></form>
379
    </div>
380
  </div>`;
381
  return layout(shell(s, center), "/", { kind: "page app-page", scripts: ["app.js"] });
382
}
383
384
/** A link to a note that isn't written yet. */
385
export function missingPage(s: Shell & { title: string }): string {
386
  const base = baseOf(s);
387
  const center = `${toolbar(s, `<a href="${base || "/"}">${escape(vaultName(s))}</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(s.title)}</span>`)}
388
  <div class="empty-state">
389
    <h1>“${escape(s.title)}” doesn't exist yet</h1>
390
    <p class="muted">A link points here, but nothing's been written.</p>
391
    <form method="post" action="${base}/open"><input type="hidden" name="title" value="${escape(s.title)}"><button class="button button-big">Create it</button></form>
392
  </div>`;
393
  return layout(shell(s, center), `${base}/open`, { name: s.title, kind: "page app-page", scripts: ["app.js"] });
394
}
395
396
/** What a search found. */
397
export function searchPage(s: Shell & { query: string; hits: readonly Hit<Note>[] }): string {
398
  const words = s.query
399
    .toLowerCase()
400
    .match(/"[^"]+"|\S+/g)
401
    ?.map((w) => w.replace(/^"|"$/g, ""))
402
    .filter((w) => !/^(#|tag:)/.test(w)) ?? [];
403
  const mark = (text: string): string => {
404
    let html = escape(text);
405
    for (const w of words) {
406
      const e = escape(w).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
407
      if (e !== "") html = html.replace(new RegExp(`(${e})`, "gi"), "<mark>$1</mark>");
408
    }
409
    return html;
410
  };
411
  const center = `${toolbar(s, `<a href="${baseOf(s) || "/"}">${escape(vaultName(s))}</a><span aria-hidden="true">/</span><span class="crumb-here">Search</span>`)}
412
  <div class="sheet results">
413
    <h1 class="results-head">${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for <q>${escape(s.query)}</q></h1>
414
    ${s.hits.length === 0 ? `<p class="muted">Nothing matches. Try fewer words, or <code>#tag</code>.</p>` : `<ul class="hits">${s.hits.map((h) => `<li><a href="${hrefOf(h.note)}"><b>${mark(h.note.title)}</b><span class="muted">${mark(h.snippet)}</span></a></li>`).join("")}</ul>`}
415
  </div>`;
416
  return layout(shell(s, center), `${baseOf(s)}/search`, { name: `Search: ${s.query}`, kind: "page app-page", scripts: ["app.js"] });
417
}
418
419
/** The whole vault, as a graph. */
420
export function graphPage(s: Shell): string {
421
  const g = graph(s.vault);
422
  if (g.nodes.length === 0) return emptyPage(s);
423
  const center = `${toolbar(s, `<a href="${baseOf(s) || "/"}">${escape(vaultName(s))}</a><span aria-hidden="true">/</span><span class="crumb-here">Graph</span>`, `<span class="muted small">${g.nodes.length} notes · ${g.edges.length} links</span>`)}
424
  <div class="graph-stage">
425
    <canvas class="big-graph" data-graph="all" aria-label="Every note, and the links between them"></canvas>
426
    <p class="graph-help muted small">Drag to move · scroll or pinch to zoom · click a note to open it</p>
427
  </div>
428
  ${graphData(s.vault)}`;
429
  return layout(shell(s, center), `${baseOf(s)}/graph`, { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] });
430
}
431
432
/** An email account: who you are, a new password, or leaving. */
433
export function accountPage(s: Shell & { account: Account }): string {
434
  const a = s.account;
435
  const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Account</span>`)}
436
  <div class="sheet results">
437
    <h1 class="results-head">${escape(a.name)}</h1>
438
    <p class="muted">${escape(a.email)} · ${s.vault.length} ${s.vault.length === 1 ? "note" : "notes"} · since ${long(a.created_at)}</p>
439
    <form class="settings" method="post" action="/account/password">
440
      <h2 class="section">Change password</h2>
441
      <label class="field"><span>Current password</span><input class="input" type="password" name="current" required autocomplete="current-password" maxlength="200"></label>
442
      <label class="field"><span>New password</span><input class="input" type="password" name="next" required minlength="8" maxlength="200" autocomplete="new-password"></label>
443
      <p class="muted small">Logs you out everywhere else.</p>
444
      <button class="button">Change password</button>
445
    </form>
446
    <form class="settings" method="post" action="/account/delete" data-confirm="Delete your account and all ${s.vault.length} notes? This cannot be undone.">
447
      <h2 class="section">Delete account</h2>
448
      <p class="muted small">Your account and every note in your vault, gone for good. <a href="/export">Download everything</a> first.</p>
449
      <label class="field"><span>Password</span><input class="input" type="password" name="password" required autocomplete="current-password" maxlength="200"></label>
450
      <button class="button button-danger">Delete my account</button>
451
    </form>
452
  </div>`;
453
  return layout(shell(s, center), "/account", { name: "Account", kind: "page app-page", scripts: ["app.js"] });
454
}
455
456
/** A shared note's file: where it is, who wrote last, whether the repository has it yet, and moving it. */
457
function filepanel(space: Space, note: Note & { path: string }): string {
458
  return `<section class="panel">
459
    <h2 class="section">File</h2>
460
    <p class="small file-path"><code>${escape(note.path)}</code></p>
461
    <p class="muted small">Last written by ${escape(note.author_name)} · ${unsynced(note) ? "waiting to commit" : "committed"}</p>
462
    <form class="move" method="post" action="/n/${note.id}/move">
463
      <label class="field"><span class="small">Folder</span><input class="input" name="folder" value="${escape(folderOf(space, note.path))}" placeholder="(top of ${escape(space.folder || "the repository")})" autocomplete="off"></label>
464
      <button class="button button-quiet">Move</button>
465
    </form>
466
  </section>`;
467
}
468
469
/** A shared folder with nothing in it yet. */
470
function emptySpace(s: Shell, space: Space): string {
471
  const base = spaceHref(space.id);
472
  const center = `${toolbar(s, `<span class="crumb-here">${escape(space.name)}</span>`)}
473
  <div class="empty-state">
474
    <div class="mark" aria-hidden="true">${icon.folder}</div>
475
    <h1>${escape(space.name)} is empty</h1>
476
    <p class="muted">Notes written here are committed to <code>${escape(space.repo)}</code> in <code>${escape(space.folder || "/")}</code> — and markdown pushed there shows up here.</p>
477
    ${space.sync_error === null ? "" : `<p class="auth-error" role="alert">${escape(space.sync_error)}</p>`}
478
    <div class="row center">
479
      <form method="post" action="${base}/new"><button class="button button-big">New note</button></form>
480
      <a class="button button-big button-quiet" href="${base}/settings">Invite someone</a>
481
    </div>
482
  </div>`;
483
  return layout(shell(s, center), base, { name: space.name, kind: "page app-page", scripts: ["app.js"] });
484
}
485
486
/** A shared folder's settings: who is in, the join link, and the repository. */
487
export function spacePage(s: Shell & { space: Space; members: readonly Member[] }): string {
488
  const space = s.space;
489
  const base = spaceHref(space.id);
490
  const mine = space.owner_id === s.who.id;
491
  const link = space.join_code === null ? null : `${publicOrigin}/join/${space.join_code}`;
492
  const center = `${toolbar(s, `<a href="${base}">${escape(space.name)}</a><span aria-hidden="true">/</span><span class="crumb-here">Sharing</span>`)}
493
  <div class="sheet results" id="space-settings" data-live="space.changed space.synced">
494
    <h1 class="results-head">${escape(space.name)}</h1>
495
    <p class="muted"><code>${escape(space.repo)}</code> · branch <code>${escape(space.branch)}</code> · folder <code>${escape(space.folder || "/")}</code></p>
496
497
    <section class="settings">
498
      <h2 class="section">Repository</h2>
499
      ${space.sync_error === null ? `<p class="muted small">${space.synced_at === null ? "Not synced yet." : `Synced ${ago(space.synced_at)}${ago(space.synced_at) === "now" ? "" : " ago"}${space.head === null ? "" : ` at <code>${escape(space.head.slice(0, 7))}</code>`}.`} Edits commit a few seconds after you stop typing; pushes to the repository show up within a minute.</p>` : `<p class="auth-error" role="alert">Last sync failed: ${escape(space.sync_error)}</p>`}
500
      <form method="post" action="${base}/sync"><button class="button">Sync now</button></form>
501
    </section>
502
503
    <section class="settings">
504
      <h2 class="section">People <span class="count">${s.members.length}</span></h2>
505
      <ul class="people">${s.members
506
        .map(
507
          (m) => `<li><span>${escape(m.who_name)}${m.who_id === space.owner_id ? ` <span class="pill">owner</span>` : ""}${m.who_id === s.who.id ? ` <span class="muted small">(you)</span>` : ""}</span>${
508
            mine && m.who_id !== space.owner_id
509
              ? `<form method="post" action="${base}/remove" data-confirm="Remove ${escape(m.who_name)} from ${escape(space.name)}?"><input type="hidden" name="member" value="${escape(m.who_id)}"><button class="button button-quiet">Remove</button></form>`
510
              : ""
511
          }</li>`,
512
        )
513
        .join("")}</ul>
514
    </section>
515
516
    ${
517
      mine
518
        ? `<section class="settings">
519
      <h2 class="section">Join link</h2>
520
      ${
521
        link === null
522
          ? `<p class="muted small">The link is off: nobody new can join.</p><form method="post" action="${base}/link"><input type="hidden" name="on" value="1"><button class="button">Make a join link</button></form>`
523
          : `<p class="muted small">Anyone signed in to notes who opens this joins <b>${escape(space.name)}</b> — and sees only this folder, never your own notes. Send it to who you're working with, then turn it off.</p>
524
      <input class="input join-link" value="${escape(link)}" readonly aria-label="Join link" data-select>
525
      <div class="row">
526
        <form method="post" action="${base}/link"><input type="hidden" name="on" value="0"><button class="button button-quiet">Turn the link off</button></form>
527
        <form method="post" action="${base}/link" data-confirm="Make a new link? The old one stops working."><input type="hidden" name="on" value="1"><button class="button button-quiet">New link</button></form>
528
      </div>`
529
      }
530
    </section>
531
    <form class="settings" method="post" action="${base}/delete" data-confirm="Stop sharing ${escape(space.name)}? Everyone loses it here. The repository keeps every file.">
532
      <h2 class="section">Stop sharing</h2>
533
      <p class="muted small">Removes the folder from notes for everyone. Nothing in the repository is touched.</p>
534
      <button class="button button-danger">Stop sharing</button>
535
    </form>`
536
        : `<form class="settings" method="post" action="${base}/leave" data-confirm="Leave ${escape(space.name)}?">
537
      <h2 class="section">Leave</h2>
538
      <p class="muted small">You won't see this folder any more, until someone sends you a join link again.</p>
539
      <button class="button button-danger">Leave</button>
540
    </form>`
541
    }
542
  </div>`;
543
  return layout(shell(s, center), `${base}/settings`, { name: `${space.name} · Sharing`, kind: "page app-page", scripts: ["app.js"] });
544
}
545
546
/** Connecting a repository folder: an admin's form. */
547
export function newSpacePage(s: Shell & { form?: { name: string; repo: string; branch: string; folder: string } }): string {
548
  const f = s.form ?? { name: "", repo: "", branch: "main", folder: "docs" };
549
  const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Share a repo folder</span>`)}
550
  <div class="sheet results">
551
    <h1 class="results-head">Share a repo folder</h1>
552
    <p class="muted">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.</p>
553
    <form class="settings" method="post" action="/spaces">
554
      <label class="field"><span>Name</span><input class="input" name="name" value="${escape(f.name)}" required maxlength="80" placeholder="Brainstorm" autofocus></label>
555
      <label class="field"><span>Repository</span><input class="input" name="repo" value="${escape(f.repo)}" required placeholder="owner/name" autocomplete="off"></label>
556
      <label class="field"><span>Branch</span><input class="input" name="branch" value="${escape(f.branch)}" required autocomplete="off"></label>
557
      <label class="field"><span>Folder <span class="muted">(empty for the whole repository)</span></span><input class="input" name="folder" value="${escape(f.folder)}" autocomplete="off"></label>
558
      <button class="button">Connect and read it</button>
559
    </form>
560
  </div>`;
561
  return layout(shell(s, center), "/spaces/new", { name: "Share a repo folder", kind: "page app-page", scripts: ["app.js"] });
562
}
563
564
/** The graph's data, for any page that draws one. */
565
export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES);
566
567
const words = (body: string): number => body.split(/\s+/).filter((w) => /\w/.test(w)).length;
568
569
const long = (iso: string): string =>
570
  new Date(iso).toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" });
571
572
export const ago = (iso: string): string => {
573
  const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
574
  if (s < 60) return "now";
575
  if (s < 3600) return `${Math.floor(s / 60)}m`;
576
  if (s < 86400) return `${Math.floor(s / 3600)}h`;
577
  if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`;
578
  return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" });
579
};
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts