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

forked from apps/glyph

workbenchthis machine
Files cd3c7d74b323cac4ab0520540ce33bcf71a582db
198 lines · 8.3 KB · ~2.4k tok Raw History
1
/**
2
 * The page, rendered on the server: the first paint is complete HTML,
3
 * `static/live.js` keeps the board fresh afterwards, and `static/glyph.js`
4
 * adds what only a script can (copy, the palette swapped in place). When a second page
5
 * arrives, move `layout` to `src/pages/layout.ts` and give each page a file.
6
 */
7
8
import { canDelete, svgsOf, type Drawing } from "./actions.ts";
9
import {
10
  COUNT,
11
  inPalette,
12
  isPalette,
13
  KINDS,
14
  nameOf,
15
  PALETTES,
16
  PROMPT_MAX,
17
  type Palette,
18
} from "./glyph.ts";
19
import {
20
  commit,
21
  description,
22
  env,
23
  publicOrigin,
24
  ssh,
25
  title,
26
  where,
27
} from "./env.ts";
28
import { asset, escape } from "./http.ts";
29
import { token, tokensHref } from "./tokens.ts";
30
import type { Who } from "./who.ts";
31
32
/**
33
 * The frame every page shares: the tokens and the look, who you are, and
34
 * what a link to the page says when it is shared — a title, a sentence
35
 * and the address, for Open Graph and the card a chat app unfurls.
36
 */
37
export function layout(body: string, who: Who, path = "/"): string {
38
  const door =
39
    who.role === "anon"
40
      ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>`
41
      : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span>
42
        <a class="button button-quiet" href="/__forge/signout">Sign out</a>`;
43
  return `<!doctype html>
44
<html lang="en">
45
<meta charset="utf-8">
46
<meta name="viewport" content="width=device-width, initial-scale=1">
47
<meta name="color-scheme" content="light dark">
48
<meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)">
49
<meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)">
50
<link rel="icon" href="data:,">
51
<title>${escape(title)}</title>
52
<meta name="description" content="${escape(description)}">
53
<meta property="og:type" content="website">
54
<meta property="og:site_name" content="${escape(title)}">
55
<meta property="og:title" content="${escape(title)}">
56
<meta property="og:description" content="${escape(description)}">
57
<meta property="og:url" content="${escape(publicOrigin + path)}">
58
<meta name="twitter:card" content="summary">
59
<meta name="twitter:title" content="${escape(title)}">
60
<meta name="twitter:description" content="${escape(description)}">
61
<link rel="stylesheet" href="${tokensHref}">
62
<link rel="stylesheet" href="${asset("app.css")}">
63
<script type="module" src="${asset("live.js")}"></script>
64
<script type="module" src="${asset("glyph.js")}"></script>
65
<body>
66
<div class="page">
67
  <header class="head">
68
    <div>
69
      <h1>${escape(title)}</h1>
70
      <p class="muted">${escape(description)}</p>
71
    </div>
72
    <nav class="row">
73
      <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""}
74
      ${door}
75
    </nav>
76
  </header>
77
  <main>${body}</main>
78
  <footer class="muted">
79
    ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
80
    <p>Also at <a href="/api/drawings">/api/drawings</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules. Drawn by Claude; every SVG is rebuilt from an allowlist before it is kept.</p>
81
    <p>Something wrong? <a href="/__forge/report">Report a problem</a>, no account needed.</p>
82
  </footer>
83
</div>
84
</body>
85
</html>
86
`;
87
}
88
89
export function home(input: {
90
  who: Who;
91
  drawings: readonly Drawing[];
92
  palette?: string | null;
93
  notice?: string | undefined;
94
}): string {
95
  const { who, drawings, notice } = input;
96
  const palette: Palette = isPalette(input.palette) ? input.palette : "sage";
97
  const composer =
98
    who.role === "anon"
99
      ? `<p class="muted">Drawings are made for whoever asks. <a href="/__forge/signin?to=/">Sign in</a> to draw.</p>`
100
      : `<form class="compose" method="post" action="/drawings">
101
        <label class="label" for="prompt">What should Claude draw?</label>
102
        <textarea class="input prompt" id="prompt" name="prompt" rows="2" required maxlength="${PROMPT_MAX}" placeholder="a fox made of circuit traces, for a home lab" autocomplete="off"></textarea>
103
        <div class="examples" hidden>${EXAMPLES.map((e) => `<button type="button" class="chip" data-example="${escape(e)}">${escape(e)}</button>`).join("")}</div>
104
        <div class="row wrap">
105
          <fieldset class="kinds" aria-label="Kind">
106
            ${Object.entries(KINDS)
107
              .map(
108
                ([k, label], i) =>
109
                  `<label class="chip"><input type="radio" name="kind" value="${k}"${i === 0 ? " checked" : ""}> ${escape(label)}</label>`,
110
              )
111
              .join("")}
112
          </fieldset>
113
          <span class="spacer"></span>
114
          <button class="button">Draw</button>
115
        </div>
116
      </form>`;
117
  return layout(
118
    `<section class="card">${composer}</section>
119
  ${palettes(palette)}
120
  <section id="board" data-live="drawing.added drawing.changed drawing.removed">${board(drawings, who, palette)}</section>
121
  ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`,
122
    who,
123
  );
124
}
125
126
const EXAMPLES = [
127
  "a hex chip with a circuit b",
128
  "a lighthouse for a monitoring app",
129
  "a rocket leaving a git branch",
130
  "a fox made of circuit traces",
131
];
132
133
/** The palette switch: links that work without JavaScript, swapped in place with it. */
134
const palettes = (current: Palette): string => `<nav class="palettes row wrap" id="palettes" aria-label="Palette">
135
  ${Object.entries(PALETTES)
136
    .map(
137
      ([key, p]) =>
138
        `<a class="chip swatch" href="?palette=${key}" data-palette="${key}"${key === current ? ` aria-current="true"` : ""}><span class="dots" style="background:${p.ground}"><i style="background:${p.ink}"></i><i style="background:${p.glow}"></i></span>${escape(p.label)}</a>`,
139
    )
140
    .join("")}
141
</nav>`;
142
143
function board(drawings: readonly Drawing[], who: Who, palette: Palette): string {
144
  if (drawings.length === 0)
145
    return `<p class="empty">Nothing drawn yet. Describe something above: Claude draws ${COUNT} different takes in about a minute.</p>`;
146
  return `<ul class="list">${drawings.map((d) => card(d, who, palette)).join("")}</ul>`;
147
}
148
149
function card(d: Drawing, who: Who, palette: Palette): string {
150
  const { ground } = PALETTES[palette]!;
151
  const svgs = svgsOf(d);
152
  const body =
153
    d.status === "drawing"
154
      ? `<div class="tiles" aria-busy="true">${Array.from({ length: COUNT }, () => `<div class="tile pending" style="background:${ground}"></div>`).join("")}</div>
155
        <p class="muted small" data-since="${escape(d.created_at)}">Claude is drawing…</p>`
156
      : d.status === "failed"
157
        ? `<p class="error">${escape(d.error ?? "Something went wrong.")}</p>`
158
        : `<div class="tiles">${svgs
159
            .map((svg, n) => {
160
              const name = nameOf(svg);
161
              const href = `/drawings/${d.id}/${n}.svg?palette=${palette}`;
162
              return `<figure class="tile-wrap">
163
            <div class="tile" style="background:${ground}"><img alt="${escape(name)}" src="data:image/svg+xml,${encodeURIComponent(inPalette(svg, palette))}"></div>
164
            <figcaption>
165
              <span class="name">${escape(name)}</span>
166
              <span class="row tight">
167
                <button type="button" class="button button-quiet small js-copy" data-href="${href}" hidden>Copy</button>
168
                <a class="button button-quiet small" href="${href}" download>Save</a>
169
              </span>
170
            </figcaption>
171
          </figure>`;
172
            })
173
            .join("")}</div>`;
174
  return `<li class="drawing" id="drawing-${d.id}">
175
  <div class="drawing-head">
176
    <div>
177
      <p class="prompt-text">${escape(d.prompt)}</p>
178
      <small class="muted">${escape(KINDS[d.kind] ?? d.kind)}${d.author_id === (who.role === "anon" ? "" : who.id) ? "" : ` · ${escape(d.author_name)}`} · <time datetime="${escape(d.created_at)}">${ago(d.created_at)}</time></small>
179
    </div>
180
    ${
181
      canDelete(who, d) && d.status !== "drawing"
182
        ? `<form method="post" action="/drawings/${d.id}/delete"><button class="button button-quiet" aria-label="Remove this drawing">Remove</button></form>`
183
        : ""
184
    }
185
  </div>
186
  ${body}
187
</li>`;
188
}
189
const ago = (iso: string): string => {
190
  const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
191
  if (s < 60) return "just now";
192
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
193
  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
194
  return new Date(iso).toLocaleDateString("en", {
195
    month: "short",
196
    day: "numeric",
197
  });
198
};
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts