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

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

Keyboard shortcuts