pike/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files acd87b6b6146da11e0c9272c92a02a8e707adf4a
123 lines · 4.7 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
    <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p>
70
  </footer>
71
</div>
72
</body>
73
</html>
74
`;
75
}
76
77
export function home(input: {
78
  who: Who;
79
  notes: readonly Note[];
80
  notice?: string | undefined;
81
}): string {
82
  const { who, notes, notice } = input;
83
  const composer =
84
    who.role === "anon"
85
      ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>`
86
      : `<form class="row" method="post" action="/notes">
87
        <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus>
88
        <button class="button">Add</button>
89
      </form>`;
90
  const list =
91
    notes.length === 0
92
      ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>`
93
      : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`;
94
  return layout(
95
    `<section class="card">${composer}</section>
96
  <section id="notes" data-live="note.added note.removed">${list}</section>
97
  ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
98
    who,
99
  );
100
}
101
102
const item = (note: Note, who: Who): string => `<li class="note">
103
  <div>
104
    <p>${escape(note.body)}</p>
105
    <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small>
106
  </div>
107
  ${
108
    canDelete(who, note)
109
      ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>`
110
      : ""
111
  }
112
</li>`;
113
114
const ago = (iso: string): string => {
115
  const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
116
  if (s < 60) return "just now";
117
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
118
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
119
  return new Date(iso).toLocaleDateString("en", {
120
    month: "short",
121
    day: "numeric",
122
  });
123
};
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts