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

Files 2b2f00dc243e6583e6d8719a8b99fe2335f1e03a
122 lines · 4.6 KB · ~1.4k tok Raw History
1
/**
2
 * The page, rendered on the server: the first paint is complete HTML, and
3
 * `static/live.js` keeps the notes fresh afterwards. When a second page
4
 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
5
 */
6
7
import { canDelete, type Note } from "./actions.ts";
8
import {
9
  commit,
10
  description,
11
  env,
12
  publicOrigin,
13
  ssh,
14
  title,
15
  where,
16
} from "./env.ts";
17
import { asset, escape } from "./http.ts";
18
import { token, tokensHref } from "./tokens.ts";
19
import type { Who } from "./who.ts";
20
21
/**
22
 * The frame every page shares: the tokens and the look, who you are, and
23
 * what a link to the page says when it is shared — a title, a sentence
24
 * and the address, for Open Graph and the card a chat app unfurls.
25
 */
26
export function layout(body: string, who: Who, path = "/"): string {
27
  const door =
28
    who.role === "anon"
29
      ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>`
30
      : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
31
        <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
32
  return `<!doctype html>
33
<html lang="en">
34
<meta charset="utf-8">
35
<meta name="viewport" content="width=device-width, initial-scale=1">
36
<meta name="color-scheme" content="light dark">
37
<meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
38
<meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
39
<link rel="icon" href="data:,">
40
<title>${escape(title)}</title>
41
<meta name="description" content="${escape(description)}">
42
<meta property="og:type" content="website">
43
<meta property="og:site_name" content="${escape(title)}">
44
<meta property="og:title" content="${escape(title)}">
45
<meta property="og:description" content="${escape(description)}">
46
<meta property="og:url" content="${escape(publicOrigin + path)}">
47
<meta name="twitter:card" content="summary">
48
<meta name="twitter:title" content="${escape(title)}">
49
<meta name="twitter:description" content="${escape(description)}">
50
<link rel="stylesheet" href="${tokensHref}">
51
<link rel="stylesheet" href="${asset("app.css")}">
52
<script type="module" src="${asset("live.js")}"></script>
53
<body>
54
<div class="page">
55
  <header class="head">
56
    <div>
57
      <h1>${escape(title)}</h1>
58
      <p class="muted">${escape(description)}</p>
59
    </div>
60
    <nav class="row">
61
      <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
62
      ${door}
63
    </nav>
64
  </header>
65
  <main>${body}</main>
66
  <footer class="muted">
67
    ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
68
    <p>Also at <a href="/api/notes">/api/notes</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules.</p>
69
  </footer>
70
</div>
71
</body>
72
</html>
73
`;
74
}
75
76
export function home(input: {
77
  who: Who;
78
  notes: readonly Note[];
79
  notice?: string | undefined;
80
}): string {
81
  const { who, notes, notice } = input;
82
  const composer =
83
    who.role === "anon"
84
      ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>`
85
      : `<form class="row" method="post" action="/notes">
86
        <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus>
87
        <button class="button">Add</button>
88
      </form>`;
89
  const list =
90
    notes.length === 0
91
      ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>`
92
      : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`;
93
  return layout(
94
    `<section class="card">${composer}</section>
95
  <section id="notes" data-live="note.added note.removed">${list}</section>
96
  ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
97
    who,
98
  );
99
}
100
101
const item = (note: Note, who: Who): string => `<li class="note">
102
  <div>
103
    <p>${escape(note.body)}</p>
104
    <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small>
105
  </div>
106
  ${
107
    canDelete(who, note)
108
      ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>`
109
      : ""
110
  }
111
</li>`;
112
113
const ago = (iso: string): string => {
114
  const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
115
  if (s < 60) return "just now";
116
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
117
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
118
  return new Date(iso).toLocaleDateString("en", {
119
    month: "short",
120
    day: "numeric",
121
  });
122
};
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts