@makerclay/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
package/ui/login.html ADDED
@@ -0,0 +1,58 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}} · makerclay</title>
7
+ <link rel="stylesheet" href="/_/ui.css">
8
+ </head>
9
+ <body>
10
+ <main class="narrow">
11
+ <div class="card">
12
+ <h1 style="margin:0 0 .25rem;font-size:1.3rem">Sign in</h1>
13
+ <p class="hint" style="margin-bottom:1rem">This server has one owner. That's you.</p>
14
+
15
+ <form id="form" method="post" action="{{action}}">
16
+ <label for="password">Password</label>
17
+ <input id="password" name="password" type="password" autocomplete="current-password" autofocus required>
18
+ <p class="hint" id="error" style="color:var(--danger)"></p>
19
+ <button class="primary" style="width:100%;margin-top:.9rem" type="submit">Sign in</button>
20
+ </form>
21
+
22
+ <p class="hint" style="margin-top:1.4rem">
23
+ Forgotten it? Run <code>makerclay reset-password</code> on the machine this
24
+ server is running on. Physical access is the only way back in, because
25
+ there is no email here to send a link to.
26
+ </p>
27
+ </div>
28
+ </main>
29
+
30
+ <script>
31
+ const form = document.getElementById('form');
32
+ const error = document.getElementById('error');
33
+ const next = new URLSearchParams(location.search).get('next');
34
+
35
+ form.addEventListener('submit', async (event) => {
36
+ event.preventDefault();
37
+ error.textContent = '';
38
+ const button = form.querySelector('button');
39
+ button.disabled = true;
40
+ try {
41
+ const res = await fetch(form.action, {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
44
+ body: JSON.stringify({ password: form.password.value, next }),
45
+ });
46
+ const body = await res.json();
47
+ if (!res.ok) throw new Error(body.msg || 'That did not work.');
48
+ location.href = body.redirect || '/_/';
49
+ } catch (problem) {
50
+ error.textContent = problem.message;
51
+ form.password.select();
52
+ } finally {
53
+ button.disabled = false;
54
+ }
55
+ });
56
+ </script>
57
+ </body>
58
+ </html>
@@ -0,0 +1,173 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}}</title>
7
+ <link rel="stylesheet" href="/_/ui.css">
8
+ <style>
9
+ td.data { font-size: .9rem; }
10
+ td.data dl { display: grid; grid-template-columns: auto 1fr; gap: .1rem .6rem; margin: 0; }
11
+ td.data dt { color: var(--muted); font-size: .82rem; }
12
+ td.data dd { margin: 0; overflow-wrap: anywhere; }
13
+ </style>
14
+ </head>
15
+ <body data-slug="{{slug}}">
16
+
17
+ <header class="bar">
18
+ <a class="btn" href="/_/" title="Back to your files">←</a>
19
+ <span class="brand">Submissions</span>
20
+ <code id="slug" style="color:var(--muted)"></code>
21
+ <span class="spacer"></span>
22
+ <button id="accepting"></button>
23
+ <button id="download">Download as JSON</button>
24
+ </header>
25
+
26
+ <main class="wrap">
27
+ <table>
28
+ <thead>
29
+ <tr><th>What was sent</th><th class="num">When</th><th class="actions">Actions</th></tr>
30
+ </thead>
31
+ <tbody id="rows"></tbody>
32
+ </table>
33
+ <div class="empty" id="empty" hidden>Nothing has been submitted yet.</div>
34
+ <p class="hint" style="margin-top:1.5rem">
35
+ Submissions are JSON files in <code>records/</code>, next to your documents, and
36
+ they are backed up by <code>makerclay export</code> like everything else.
37
+ Deleting one moves it to the trash; it can come back.
38
+ </p>
39
+ </main>
40
+
41
+ <div class="toast" id="toast"></div>
42
+
43
+ <script>
44
+ const slug = document.body.dataset.slug;
45
+ const rows = document.getElementById('rows');
46
+ const empty = document.getElementById('empty');
47
+ const toastEl = document.getElementById('toast');
48
+ document.getElementById('slug').textContent = slug;
49
+
50
+ let records = [];
51
+ let accepting = true;
52
+
53
+ function toast(message, isError) {
54
+ toastEl.textContent = message;
55
+ toastEl.className = 'toast show' + (isError ? ' error' : '');
56
+ clearTimeout(toast.timer);
57
+ toast.timer = setTimeout(() => { toastEl.className = 'toast'; }, 2600);
58
+ }
59
+
60
+ async function api(path, body, method) {
61
+ const res = await fetch(path, {
62
+ method: method || (body ? 'POST' : 'GET'),
63
+ headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' }
64
+ : { Accept: 'application/json' },
65
+ body: body ? JSON.stringify(body) : undefined,
66
+ });
67
+ const parsed = await res.json().catch(() => ({}));
68
+ if (!res.ok) throw new Error(parsed.msg || res.statusText);
69
+ return parsed;
70
+ }
71
+
72
+ // Whatever the form sent, rendered as text. Never as HTML: this is the one page
73
+ // on the host that displays bytes a stranger typed.
74
+ function fields(data) {
75
+ const list = document.createElement('dl');
76
+ for (const [key, value] of Object.entries(data || {})) {
77
+ const dt = document.createElement('dt');
78
+ dt.textContent = key;
79
+ const dd = document.createElement('dd');
80
+ dd.textContent = typeof value === 'string' ? value : JSON.stringify(value);
81
+ list.append(dt, dd);
82
+ }
83
+ return list.children.length ? list : Object.assign(document.createElement('span'),
84
+ { className: 'hint', textContent: '(empty)' });
85
+ }
86
+
87
+ function drawRow(record) {
88
+ const tr = document.createElement('tr');
89
+
90
+ const data = document.createElement('td');
91
+ data.className = 'data';
92
+ data.append(fields(record.data));
93
+ if (record.codeMissing) {
94
+ data.insertAdjacentHTML('beforeend',
95
+ '<span class="tag private">locked: its code is not in the database</span>');
96
+ } else if (record.hasCode) {
97
+ data.insertAdjacentHTML('beforeend', '<span class="tag">editable by its sender</span>');
98
+ }
99
+ tr.appendChild(data);
100
+
101
+ const when = document.createElement('td');
102
+ when.className = 'num';
103
+ when.textContent = new Date(record.updatedAt).toLocaleString();
104
+ tr.appendChild(when);
105
+
106
+ const actions = document.createElement('td');
107
+ actions.className = 'actions';
108
+ const remove = document.createElement('button');
109
+ remove.textContent = 'Delete';
110
+ remove.className = 'danger';
111
+ remove.addEventListener('click', () => run(
112
+ () => api(`/_/collection/${slug}/records/${encodeURIComponent(record.key)}`, null, 'DELETE'),
113
+ 'Moved to the trash',
114
+ ));
115
+ actions.appendChild(remove);
116
+ tr.appendChild(actions);
117
+
118
+ return tr;
119
+ }
120
+
121
+ async function load() {
122
+ try {
123
+ const body = await api(`/_/collection/${slug}/records`);
124
+ records = body.records;
125
+ accepting = body.accepting;
126
+ drawAccepting();
127
+ rows.replaceChildren(...records.map(drawRow));
128
+ empty.hidden = records.length > 0;
129
+ } catch (problem) {
130
+ toast(problem.message, true);
131
+ }
132
+ }
133
+
134
+ async function run(work, message) {
135
+ try {
136
+ await work();
137
+ toast(message);
138
+ load();
139
+ } catch (problem) {
140
+ toast(problem.message, true);
141
+ }
142
+ }
143
+
144
+ const acceptingButton = document.getElementById('accepting');
145
+ function drawAccepting() {
146
+ acceptingButton.textContent = accepting ? 'Accepting submissions' : 'Closed to submissions';
147
+ acceptingButton.className = accepting ? 'primary' : '';
148
+ }
149
+ acceptingButton.addEventListener('click', async () => {
150
+ try {
151
+ const body = await api(`/_/collection/${slug}/accepting`, { accepting: !accepting });
152
+ accepting = body.accepting;
153
+ drawAccepting();
154
+ toast(accepting ? 'Open again' : 'Closed. Existing submissions are untouched.');
155
+ } catch (problem) {
156
+ toast(problem.message, true);
157
+ }
158
+ });
159
+
160
+ document.getElementById('download').addEventListener('click', () => {
161
+ const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' });
162
+ const link = document.createElement('a');
163
+ link.href = URL.createObjectURL(blob);
164
+ link.download = `${slug}.json`;
165
+ link.click();
166
+ URL.revokeObjectURL(link.href);
167
+ });
168
+
169
+ drawAccepting();
170
+ load();
171
+ </script>
172
+ </body>
173
+ </html>
@@ -0,0 +1,152 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Recovery · makerclay</title>
7
+ <style>
8
+ :root { --bg:#fbfaf8; --panel:#fff; --ink:#1c1b19; --muted:#6b6862; --line:#e3dfd8;
9
+ --accent:#9a5b28; --danger:#a3342a; color-scheme: light dark; }
10
+ @media (prefers-color-scheme: dark) {
11
+ :root { --bg:#171614; --panel:#1f1e1b; --ink:#ece8e1; --muted:#9b968d; --line:#322f2a;
12
+ --accent:#d98b4a; --danger:#e07a6c; }
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { margin:0; background:var(--bg); color:var(--ink);
16
+ font:15px/1.55 ui-sans-serif, system-ui, sans-serif; }
17
+ .wrap { max-width:52rem; margin:0 auto; padding:2.5rem 1.25rem 5rem; }
18
+ h1 { font-size:1.4rem; margin:0 0 .3rem; }
19
+ h2 { font-size:.82rem; text-transform:uppercase; letter-spacing:.06em; color:var(--muted);
20
+ margin:2.2rem 0 .6rem; }
21
+ code, .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size:.92em; }
22
+ .banner { border:1px solid var(--danger); border-radius:8px; padding:.9rem 1rem;
23
+ background:color-mix(in oklab, var(--danger) 10%, transparent); }
24
+ .banner p { margin:.4rem 0 0; }
25
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:1rem; }
26
+ table { width:100%; border-collapse:collapse; }
27
+ th, td { text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--line); }
28
+ th { font-size:.75rem; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
29
+ td.num { text-align:right; font-variant-numeric:tabular-nums; color:var(--muted); }
30
+ a { color:var(--accent); }
31
+ .files { max-height:22rem; overflow:auto; }
32
+ .hint { color:var(--muted); font-size:.88rem; }
33
+ pre { background:var(--panel); border:1px solid var(--line); border-radius:8px;
34
+ padding:.8rem; overflow:auto; max-height:18rem; }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <div class="wrap">
39
+
40
+ <div class="banner">
41
+ <h1>makerclay is in recovery mode</h1>
42
+ <p>It is not serving any documents. The reason it gives is
43
+ <code id="reason">{{reason}}</code>.</p>
44
+ <p>Your files were never the database. They are still ordinary HTML on disk at
45
+ <code>{{data}}</code>, and you can take them with you from this page.</p>
46
+ </div>
47
+
48
+ <h2>What it found</h2>
49
+ <div class="card" id="report">Reading…</div>
50
+
51
+ <h2>Take a copy</h2>
52
+ <div class="card">
53
+ <p style="margin-top:0" id="artifacts"></p>
54
+ <p class="hint">For one archive of everything, stop the server and run
55
+ <code>makerclay export</code>. It refuses to run while this process holds the
56
+ lock, because an archive taken mid-write can capture a half-applied change.</p>
57
+ </div>
58
+
59
+ <h2>Every file on disk</h2>
60
+ <div class="card files" id="tree">Reading…</div>
61
+
62
+ <h2>What to do next</h2>
63
+ <div class="card">
64
+ <p style="margin-top:0">Run <code>makerclay doctor</code> in a terminal. It prints a
65
+ repair plan and changes nothing on its own.</p>
66
+ <p class="hint">This page opens only with the key printed in the server's own console,
67
+ and every link here downloads bytes rather than opening them. Nothing on it
68
+ writes anything.</p>
69
+ </div>
70
+
71
+ </div>
72
+
73
+ <script>
74
+ const bytes = (n) => n == null ? '' : n < 1024 ? n + ' B'
75
+ : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
76
+
77
+ // The key that got this page open is the key everything on it uses. It is in
78
+ // the address bar because a download link cannot carry a header.
79
+ const key = new URLSearchParams(location.search).get('key') || '';
80
+ const withKey = (url) => url + (url.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(key);
81
+
82
+ async function load() {
83
+ const report = await fetch(withKey('/_/recover/report')).then(r => r.json());
84
+
85
+ const rows = [
86
+ ['state', report.state + (report.reason ? ' (' + report.reason + ')' : '')],
87
+ ['documents indexed', report.counts.nodes ?? report.counts.error],
88
+ ['in the trash', report.counts.trash ?? ''],
89
+ ['unfinished operations', report.counts.operations ?? ''],
90
+ ['durable records', report.records],
91
+ ['database', Array.isArray(report.integrity) ? report.integrity.join('; ') : report.integrity.error],
92
+ ];
93
+ const table = document.createElement('table');
94
+ for (const [label, value] of rows) {
95
+ const tr = document.createElement('tr');
96
+ const th = document.createElement('th'); th.textContent = label;
97
+ const td = document.createElement('td'); td.textContent = String(value);
98
+ tr.append(th, td); table.append(tr);
99
+ }
100
+ const box = document.getElementById('report');
101
+ box.replaceChildren(table);
102
+
103
+ if (report.error) {
104
+ const pre = document.createElement('pre');
105
+ pre.textContent = report.error;
106
+ box.append(pre);
107
+ }
108
+ if (report.operations && report.operations.length) {
109
+ const pre = document.createElement('pre');
110
+ pre.textContent = report.operations.map(op => op.kind + ' ' + JSON.stringify(op.payload)).join('\n');
111
+ box.append(pre);
112
+ }
113
+
114
+ const links = report.artifacts.filter(a => a.exists).map(a => {
115
+ const link = document.createElement('a');
116
+ link.href = withKey('/_/recover/raw?path=' + encodeURIComponent(a.key));
117
+ link.textContent = a.name + ' (' + bytes(a.size) + ')';
118
+ return link;
119
+ });
120
+ const holder = document.getElementById('artifacts');
121
+ holder.replaceChildren(...links.flatMap((link, i) => i ? [document.createTextNode(' · '), link] : [link]));
122
+
123
+ const { trees } = await fetch(withKey('/_/recover/tree')).then(r => r.json());
124
+ const files = document.createElement('table');
125
+ for (const tree of trees) {
126
+ for (const file of tree.files) {
127
+ const tr = document.createElement('tr');
128
+ const name = document.createElement('td');
129
+ const link = document.createElement('a');
130
+ link.href = withKey('/_/recover/raw?path=' + encodeURIComponent(file.path));
131
+ link.textContent = file.path;
132
+ link.className = 'mono';
133
+ name.append(link);
134
+ const size = document.createElement('td');
135
+ size.className = 'num';
136
+ size.textContent = bytes(file.size);
137
+ tr.append(name, size);
138
+ files.append(tr);
139
+ }
140
+ }
141
+ document.getElementById('tree').replaceChildren(
142
+ files.rows.length ? files : Object.assign(document.createElement('p'),
143
+ { className: 'hint', textContent: 'No files found on disk.' }),
144
+ );
145
+ }
146
+
147
+ load().catch((problem) => {
148
+ document.getElementById('report').textContent = 'Could not read the report: ' + problem.message;
149
+ });
150
+ </script>
151
+ </body>
152
+ </html>
package/ui/setup.html ADDED
@@ -0,0 +1,61 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}} · makerclay</title>
7
+ <link rel="stylesheet" href="/_/ui.css">
8
+ </head>
9
+ <body>
10
+ <main class="narrow">
11
+ <div class="card">
12
+ <h1 style="margin:0 0 .25rem;font-size:1.3rem">{{title}}</h1>
13
+ <p class="hint" style="margin-bottom:1rem">
14
+ Paste the key printed in the terminal, then choose a password. The key is
15
+ what stops anyone else claiming this server before you get to it.
16
+ </p>
17
+
18
+ <form id="form" method="post" action="{{action}}">
19
+ <label for="key">Key from the terminal</label>
20
+ <input id="key" name="key" type="text" class="mono" value="{{key}}" autocomplete="off"
21
+ spellcheck="false" required>
22
+
23
+ <label for="password">Choose a password</label>
24
+ <input id="password" name="password" type="password" autocomplete="new-password"
25
+ minlength="8" required>
26
+ <p class="hint">At least 8 characters. Write it down: there is no reset email.</p>
27
+
28
+ <p class="hint" id="error" style="color:var(--danger)"></p>
29
+ <button class="primary" style="width:100%;margin-top:.9rem" type="submit">Save and continue</button>
30
+ </form>
31
+ </div>
32
+ </main>
33
+
34
+ <script>
35
+ const form = document.getElementById('form');
36
+ const error = document.getElementById('error');
37
+ if (form.key.value) form.password.focus(); else form.key.focus();
38
+
39
+ form.addEventListener('submit', async (event) => {
40
+ event.preventDefault();
41
+ error.textContent = '';
42
+ const button = form.querySelector('button');
43
+ button.disabled = true;
44
+ try {
45
+ const res = await fetch(form.action, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
48
+ body: JSON.stringify({ key: form.key.value.trim(), password: form.password.value }),
49
+ });
50
+ const body = await res.json();
51
+ if (!res.ok) throw new Error(body.msg || 'That did not work.');
52
+ location.href = body.redirect || '/_/';
53
+ } catch (problem) {
54
+ error.textContent = problem.message;
55
+ } finally {
56
+ button.disabled = false;
57
+ }
58
+ });
59
+ </script>
60
+ </body>
61
+ </html>
@@ -0,0 +1,44 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}} · makerclay</title>
7
+ <link rel="stylesheet" href="/_/ui.css">
8
+ <style>
9
+ .qr { display: grid; place-items: center; padding: 1rem; background: #fff; border-radius: var(--radius); }
10
+ .qr svg { width: min(60vw, 15rem); height: auto; display: block; }
11
+ .link { word-break: break-all; margin-top: 1rem; }
12
+ @media print { header.bar, .noprint { display: none } }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <main class="narrow">
17
+ <div class="card" style="text-align:center">
18
+ <h1 style="margin:0 0 .2rem;font-size:1.2rem">{{name}}</h1>
19
+ <p class="hint" style="margin:0 0 1rem">Anyone with this link can {{grant}} it.</p>
20
+
21
+ <div class="qr">{{{qr}}}</div>
22
+
23
+ <p class="link mono">{{url}}</p>
24
+
25
+ <div class="row noprint" style="justify-content:center;margin-top:1rem">
26
+ <button id="copy">Copy link</button>
27
+ <button onclick="print()">Print</button>
28
+ </div>
29
+ <p class="hint noprint" style="margin-top:1rem">
30
+ This is the only page that shows the link in full. The server keeps a hash
31
+ of it, so it cannot show it to you again: mint a new link if you lose it.
32
+ </p>
33
+ </div>
34
+ </main>
35
+
36
+ <script>
37
+ document.getElementById('copy').addEventListener('click', async (event) => {
38
+ await navigator.clipboard.writeText(document.querySelector('.link').textContent.trim());
39
+ event.target.textContent = 'Copied';
40
+ setTimeout(() => { event.target.textContent = 'Copy link'; }, 1500);
41
+ });
42
+ </script>
43
+ </body>
44
+ </html>
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Untitled</title>
7
+ <script src="/_/clay.js"></script>
8
+ <style>
9
+ body { font: 16px/1.6 system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <h1 contenteditable>Untitled</h1>
14
+ <p contenteditable>Click Edit, change anything, then save. The file you are reading is the database.</p>
15
+ </body>
16
+ </html>
@@ -0,0 +1,71 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Devlog</title>
7
+ <script src="/_/clay.js?plugins=undo"></script>
8
+ <style>
9
+ :root { color-scheme: light dark; --ink: #1b1b1f; --paper: #fbfaf8; --line: #e2e1dd;
10
+ --muted: #75746e; --accent: #b0562a; }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root { --ink: #e9e8e4; --paper: #141416; --line: #2b2b30; --muted: #98968f; --accent: #d98b4a; }
13
+ }
14
+ body { font: 16px/1.65 system-ui, sans-serif; color: var(--ink); background: var(--paper);
15
+ max-width: 42rem; margin: 0 auto; padding: 3.5rem 1.25rem 6rem; }
16
+ header { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 2.5rem; }
17
+ h1 { font-size: 1.5rem; margin: 0; }
18
+ .tagline { color: var(--muted); font-size: .95rem; margin: 0; }
19
+ entry, .entry { display: block; border-top: 1px solid var(--line); padding: 1.4rem 0; }
20
+ .entry:first-of-type { border-top: none; }
21
+ .when { color: var(--muted); font-size: .82rem; text-transform: uppercase; letter-spacing: .06em; }
22
+ .entry h2 { font-size: 1.1rem; margin: .3rem 0 .5rem; }
23
+ .entry .body { margin: 0; }
24
+ button { font: inherit; background: none; border: 1px dashed var(--line); color: var(--muted);
25
+ border-radius: 7px; padding: .5rem .9rem; cursor: pointer; margin-bottom: 2rem; }
26
+ button:hover { color: var(--ink); border-color: var(--muted); }
27
+ html[editmode="false"] button { display: none; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+
32
+ <header>
33
+ <h1 contenteditable>Devlog</h1>
34
+ <p class="tagline" contenteditable>what changed, and why</p>
35
+ </header>
36
+
37
+ <button onclick="addEntry()">+ New entry</button>
38
+
39
+ <div id="entries">
40
+ <article class="entry">
41
+ <div class="when" contenteditable>Today</div>
42
+ <h2 contenteditable>Started this log</h2>
43
+ <div class="body" contenteditable>
44
+ <p>Newest entry at the top. The date is text, not a timestamp, so you can
45
+ write “last Tuesday” or “week 3” if that is what you actually mean.</p>
46
+ </div>
47
+ </article>
48
+ </div>
49
+
50
+ <script>
51
+ // Written at click time, not at load time: a date computed on every page view
52
+ // would rewrite the file's meaning every day it is opened.
53
+ function addEntry() {
54
+ const entries = document.getElementById('entries');
55
+ const entry = document.createElement('article');
56
+ entry.className = 'entry';
57
+ entry.innerHTML = '<div class="when" contenteditable></div>'
58
+ + '<h2 contenteditable>Untitled</h2>'
59
+ + '<div class="body" contenteditable><p>…</p></div>';
60
+ entry.querySelector('.when').textContent = new Date().toLocaleDateString(undefined, {
61
+ year: 'numeric', month: 'long', day: 'numeric',
62
+ });
63
+ entries.prepend(entry);
64
+ const heading = entry.querySelector('h2');
65
+ heading.focus();
66
+ getSelection().selectAllChildren(heading);
67
+ }
68
+ </script>
69
+
70
+ </body>
71
+ </html>