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

Files 1060c95de59213030a45f268d791921201715cde
324 lines · 19.2 KB · ~5.6k 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 { canDelete, type Note } from "./actions.ts";
10
import { commit, description, env, publicOrigin, title, where } from "./env.ts";
11
import { asset, escape } from "./http.ts";
12
import { token, tokensHref } from "./tokens.ts";
13
import {
14
  backlinks,
15
  excerpt,
16
  graph,
17
  headings,
18
  hrefOf,
19
  outgoing,
20
  renderNote,
21
  tagCounts,
22
  tags,
23
  type Hit,
24
} from "./vault.ts";
25
import type { Who } from "./who.ts";
26
27
/** Data for the browser, inside a page: JSON that can never close its <script>. */
28
const data = (id: string, value: unknown, live = ""): string =>
29
  `<script type="application/json" id="${id}"${live ? ` data-live="${live}"` : ""}>${JSON.stringify(value).replace(/</g, "\\u003c")}</script>`;
30
31
const CHANGES = "note.added note.removed note.changed";
32
33
/**
34
 * The frame every page shares: the tokens and the look, and what a link
35
 * to the page says when it is shared — a title, a sentence and the
36
 * address, for Open Graph and the card a chat app unfurls.
37
 */
38
export function layout(
39
  body: string,
40
  path = "/",
41
  options: { name?: string; kind?: string; scripts?: string[] } = {},
42
): string {
43
  const name = options.name === undefined ? title : `${options.name} · ${title}`;
44
  return `<!doctype html>
45
<html lang="en">
46
<meta charset="utf-8">
47
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
48
<meta name="color-scheme" content="light dark">
49
<meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
50
<meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
51
<link rel="icon" href="data:,">
52
<title>${escape(name)}</title>
53
<meta name="description" content="${escape(description)}">
54
<meta property="og:type" content="website">
55
<meta property="og:site_name" content="${escape(title)}">
56
<meta property="og:title" content="${escape(title)}">
57
<meta property="og:description" content="${escape(description)}">
58
<meta property="og:url" content="${escape(publicOrigin + path)}">
59
<meta name="twitter:card" content="summary">
60
<meta name="twitter:title" content="${escape(title)}">
61
<meta name="twitter:description" content="${escape(description)}">
62
<script src="${asset("theme.js")}"></script>
63
<link rel="stylesheet" href="${tokensHref}">
64
<link rel="stylesheet" href="${asset("app.css")}">
65
<script type="module" src="${asset("live.js")}"></script>
66
${(options.scripts ?? []).map((s) => `<script type="module" src="${asset(s)}"></script>`).join("\n")}
67
<body class="${escape(options.kind ?? "page")}">
68
${body}
69
</body>
70
</html>
71
`;
72
}
73
74
/** Signed out: what this is, and the way in. */
75
export function landing(notice?: string): string {
76
  return layout(
77
    `<div class="landing">
78
  <div class="landing-card">
79
    <div class="mark" aria-hidden="true">${glyph}</div>
80
    <h1>${escape(title)}</h1>
81
    <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>
82
    <a class="button button-big" href="/__forge/signin?to=/">Sign in to open your vault</a>
83
    <p class="muted small">Your notes are yours alone — nobody else who can open this app can read them.</p>
84
  </div>
85
  ${toast(notice)}
86
</div>`,
87
    "/",
88
    { kind: "page landing-page" },
89
  );
90
}
91
92
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>`;
93
94
const toast = (notice?: string): string =>
95
  notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`;
96
97
const icon = {
98
  plus: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`,
99
  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>`,
100
  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>`,
101
  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>`,
102
  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>`,
103
  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>`,
104
  pin: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4h6l-1 6 4 3v2H6v-2l4-3zM12 15v6"/></svg>`,
105
  trash: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/></svg>`,
106
  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>`,
107
  download: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>`,
108
};
109
110
interface Shell {
111
  readonly who: Who & { role: "user" | "admin" };
112
  readonly vault: readonly Note[];
113
  readonly active?: Note | undefined;
114
  readonly notice?: string | undefined;
115
  readonly query?: string;
116
}
117
118
/** The left: making notes, finding them, and every one of them. */
119
function left(s: Shell): string {
120
  const pinned = s.vault.filter((n) => n.pinned === 1);
121
  const rest = s.vault.filter((n) => n.pinned !== 1);
122
  const row = (n: Note) =>
123
    `<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>`;
124
  const tagList = tagCounts(s.vault);
125
  return `<aside class="left" id="left" aria-label="Your notes">
126
  <div class="left-head">
127
    <a class="brand" href="/">${glyph}<span>${escape(title)}</span></a>
128
    <button type="button" class="icon-button" data-action="theme" aria-label="Switch theme" title="Theme">${icon.theme}</button>
129
  </div>
130
  <div class="actions">
131
    <form method="post" action="/new"><button class="button button-wide" title="New note (Alt N)">${icon.plus}<span>New note</span></button></form>
132
    <form method="post" action="/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>
133
    <a class="icon-button" href="/graph" aria-label="Graph" title="Graph (⌘G)">${icon.graph}</a>
134
  </div>
135
  <form class="find" method="get" action="/search" role="search">
136
    ${icon.search}
137
    <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>
138
    <kbd class="hint">⌘K</kbd>
139
  </form>
140
  <nav class="files" id="files" data-live="${CHANGES}">
141
    ${s.vault.length === 0 ? `<p class="muted small pad">No notes yet.</p>` : ""}
142
    ${pinned.length > 0 ? `<p class="section">Pinned</p><ul class="file-list">${pinned.map(row).join("")}</ul>` : ""}
143
    ${rest.length > 0 ? `<p class="section">${pinned.length > 0 ? "Notes" : `Notes <span class="count">${rest.length}</span>`}</p><ul class="file-list">${rest.map(row).join("")}</ul>` : ""}
144
    ${tagList.length > 0 ? `<p class="section">Tags</p><div class="tag-cloud">${tagList.map((t) => `<a class="tag" href="/search?q=${encodeURIComponent(`#${t.tag}`)}">#${escape(t.tag)}<span class="count">${t.count}</span></a>`).join("")}</div>` : ""}
145
  </nav>
146
  <div class="left-foot">
147
    <span class="you" title="${escape(where)}${commit ? ` · ${escape(commit)}` : ""}">${escape(s.who.name)}${env === "production" ? "" : ` · <span class="pill">${escape(where)}</span>`}</span>
148
    <a class="icon-button" href="/export" aria-label="Download every note" title="Download everything">${icon.download}</a>
149
    <a class="button button-quiet" href="/__forge/signout">Sign out</a>
150
  </div>
151
</aside>`;
152
}
153
154
/** The right: the note's shape and its neighbours. */
155
function right(note: Note, vault: readonly Note[]): string {
156
  const outline = headings(note.body);
157
  const back = backlinks(note, vault);
158
  const out = outgoing(note, vault);
159
  const mine = tags(note.body);
160
  const backTitle = note.title.toLowerCase();
161
  const context = (n: Note): string => {
162
    const line = n.body
163
      .split("\n")
164
      .find((l) => l.toLowerCase().includes(`[[${backTitle}`));
165
    return line === undefined ? "" : `<span class="context">${escape(excerpt(line, 110))}</span>`;
166
  };
167
  return `<aside class="right" id="context" aria-label="About this note" data-live="${CHANGES}">
168
  <section class="panel">
169
    <h2 class="section">Graph</h2>
170
    <canvas class="local-graph" data-graph="local" data-focus="${note.id}" aria-label="This note and its neighbours"></canvas>
171
  </section>
172
  <section class="panel">
173
    <h2 class="section">Backlinks <span class="count">${back.length}</span></h2>
174
    ${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>`}
175
  </section>
176
  <section class="panel">
177
    <h2 class="section">Links <span class="count">${out.found.length + out.missing.length}</span></h2>
178
    ${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="/open?title=${encodeURIComponent(t)}" data-title="${escape(t)}">${escape(t)}</a></li>`).join("")}</ul>`}
179
  </section>
180
  ${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>` : ""}
181
  ${mine.length > 0 ? `<section class="panel"><h2 class="section">Tags</h2><div class="tag-cloud">${mine.map((t) => `<a class="tag" href="/search?q=${encodeURIComponent(`#${t}`)}">#${escape(t)}</a>`).join("")}</div></section>` : ""}
182
  <section class="panel meta muted small">
183
    <p>Created <time datetime="${escape(note.created_at)}">${long(note.created_at)}</time></p>
184
    <p>${words(note.body)} words · ${note.body.length.toLocaleString("en")} characters</p>
185
  </section>
186
</aside>`;
187
}
188
189
/** Everything around a center: the sidebars, the toolbar's toggles, the switcher, the vault's titles for the browser. */
190
function shell(s: Shell, center: string, rightSide = ""): string {
191
  return `<div class="shell" data-has-right="${rightSide === "" ? "false" : "true"}">
192
  ${left(s)}
193
  <div class="scrim" data-action="close-panels" hidden></div>
194
  <main class="center" id="main">${center}</main>
195
  ${rightSide}
196
</div>
197
<div class="palette" id="palette" hidden>
198
  <div class="palette-box" role="dialog" aria-modal="true" aria-label="Go to a note">
199
    <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">
200
    <ul class="palette-list" id="palette-list" role="listbox"></ul>
201
    <p class="palette-help muted small"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>↵</kbd> open · <kbd>⇧↵</kbd> create · <kbd>esc</kbd> close</p>
202
  </div>
203
</div>
204
<ul class="suggest" id="suggest" role="listbox" hidden></ul>
205
${data("vault", s.vault.map((n) => ({ id: n.id, title: n.title, updated_at: n.updated_at })), CHANGES)}
206
${toast(s.notice)}`;
207
}
208
209
const toolbar = (s: Shell, crumbs: string, tools = ""): string => `<header class="toolbar">
210
  <button type="button" class="icon-button only-narrow" data-action="toggle-left" aria-label="Show notes">${icon.left}</button>
211
  <div class="crumbs">${crumbs}</div>
212
  <div class="tools">${tools}</div>
213
  ${s.active === undefined ? "" : `<button type="button" class="icon-button only-narrow-right" data-action="toggle-right" aria-label="Show links">${icon.right}</button>`}
214
</header>`;
215
216
/** One note, open: the editor and the reading view side by side. */
217
export function notePage(s: Shell & { active: Note }): string {
218
  const note = s.active;
219
  const tools = `<div class="segmented" role="group" aria-label="View">
220
      <button type="button" data-mode="edit" aria-pressed="false" title="Edit">Edit</button>
221
      <button type="button" data-mode="split" aria-pressed="false" title="Edit and read side by side" class="only-wide">Split</button>
222
      <button type="button" data-mode="read" aria-pressed="false" title="Read (⌘E)">Read</button>
223
    </div>
224
    <span class="status muted small" id="status" aria-live="polite">Saved</span>
225
    <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>
226
    <a class="icon-button" href="/n/${note.id}/raw" aria-label="Download as markdown" title="Download .md">${icon.download}</a>
227
    ${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>` : ""}`;
228
  const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(note.title)}</span>`, tools)}
229
  <form class="editor" id="editor" method="post" action="/n/${note.id}" data-id="${note.id}" data-updated="${escape(note.updated_at)}">
230
    <div class="sheet">
231
      <input class="title-input" id="title" name="title" value="${escape(note.title)}" aria-label="Title" maxlength="200" autocomplete="off" spellcheck="false">
232
      <div class="panes" id="panes" data-mode="${note.body.trim() === "" ? "edit" : "split"}">
233
        <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>
234
        <article class="md preview" id="preview" aria-label="Reading view">${renderNote(note.body, s.vault) || `<p class="muted">Nothing here yet.</p>`}</article>
235
      </div>
236
      <noscript><p class="row"><button class="button">Save</button></p></noscript>
237
    </div>
238
  </form>
239
  ${graphData(s.vault)}`;
240
  return layout(shell(s, center, right(note, s.vault)), hrefOf(note), {
241
    name: note.title,
242
    kind: "page app-page",
243
    scripts: ["app.js", "graph.js"],
244
  });
245
}
246
247
/** An empty vault: two ways to begin. */
248
export function emptyPage(s: Shell): string {
249
  const center = `${toolbar(s, `<span class="crumb-here">Vault</span>`)}
250
  <div class="empty-state">
251
    <div class="mark" aria-hidden="true">${glyph}</div>
252
    <h1>Your vault is empty</h1>
253
    <p class="muted">Every note you write here is markdown, saved as you type, and private to you.</p>
254
    <div class="row center">
255
      <form method="post" action="/welcome"><button class="button button-big">Start with a welcome note</button></form>
256
      <form method="post" action="/new"><button class="button button-big button-quiet">Blank note</button></form>
257
    </div>
258
  </div>`;
259
  return layout(shell(s, center), "/", { kind: "page app-page", scripts: ["app.js"] });
260
}
261
262
/** A link to a note that isn't written yet. */
263
export function missingPage(s: Shell & { title: string }): string {
264
  const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">${escape(s.title)}</span>`)}
265
  <div class="empty-state">
266
    <h1>“${escape(s.title)}” doesn't exist yet</h1>
267
    <p class="muted">A link points here, but nothing's been written.</p>
268
    <form method="post" action="/open"><input type="hidden" name="title" value="${escape(s.title)}"><button class="button button-big">Create it</button></form>
269
  </div>`;
270
  return layout(shell(s, center), "/open", { name: s.title, kind: "page app-page", scripts: ["app.js"] });
271
}
272
273
/** What a search found. */
274
export function searchPage(s: Shell & { query: string; hits: readonly Hit<Note>[] }): string {
275
  const words = s.query
276
    .toLowerCase()
277
    .match(/"[^"]+"|\S+/g)
278
    ?.map((w) => w.replace(/^"|"$/g, ""))
279
    .filter((w) => !/^(#|tag:)/.test(w)) ?? [];
280
  const mark = (text: string): string => {
281
    let html = escape(text);
282
    for (const w of words) {
283
      const e = escape(w).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
284
      if (e !== "") html = html.replace(new RegExp(`(${e})`, "gi"), "<mark>$1</mark>");
285
    }
286
    return html;
287
  };
288
  const center = `${toolbar(s, `<a href="/">Vault</a><span aria-hidden="true">/</span><span class="crumb-here">Search</span>`)}
289
  <div class="sheet results">
290
    <h1 class="results-head">${s.hits.length} ${s.hits.length === 1 ? "note" : "notes"} for <q>${escape(s.query)}</q></h1>
291
    ${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>`}
292
  </div>`;
293
  return layout(shell(s, center), "/search", { name: `Search: ${s.query}`, kind: "page app-page", scripts: ["app.js"] });
294
}
295
296
/** The whole vault, as a graph. */
297
export function graphPage(s: Shell): string {
298
  const g = graph(s.vault);
299
  if (g.nodes.length === 0) return emptyPage(s);
300
  const center = `${toolbar(s, `<a href="/">Vault</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>`)}
301
  <div class="graph-stage">
302
    <canvas class="big-graph" data-graph="all" aria-label="Every note, and the links between them"></canvas>
303
    <p class="graph-help muted small">Drag to move · scroll or pinch to zoom · click a note to open it</p>
304
  </div>
305
  ${graphData(s.vault)}`;
306
  return layout(shell(s, center), "/graph", { name: "Graph", kind: "page app-page graph-page", scripts: ["app.js", "graph.js"] });
307
}
308
309
/** The graph's data, for any page that draws one. */
310
export const graphData = (vault: readonly Note[]): string => data("graph", graph(vault), CHANGES);
311
312
const words = (body: string): number => body.split(/\s+/).filter((w) => /\w/.test(w)).length;
313
314
const long = (iso: string): string =>
315
  new Date(iso).toLocaleDateString("en", { year: "numeric", month: "short", day: "numeric" });
316
317
export const ago = (iso: string): string => {
318
  const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
319
  if (s < 60) return "now";
320
  if (s < 3600) return `${Math.floor(s / 60)}m`;
321
  if (s < 86400) return `${Math.floor(s / 3600)}h`;
322
  if (s < 86400 * 7) return `${Math.floor(s / 86400)}d`;
323
  return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" });
324
};
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts