@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.
- package/LICENSE +221 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/admin/listing.js +149 -0
- package/src/admin/routes.js +362 -0
- package/src/attic.js +168 -0
- package/src/auth/can.js +79 -0
- package/src/auth/csrf.js +74 -0
- package/src/auth/none.js +24 -0
- package/src/auth/password.js +281 -0
- package/src/auth/passwords.js +79 -0
- package/src/auth/rate-limit.js +66 -0
- package/src/auth/sessions.js +86 -0
- package/src/auth/token-lanes.js +72 -0
- package/src/boot.js +120 -0
- package/src/client.js +99 -0
- package/src/collections/index.js +397 -0
- package/src/collections/routes.js +166 -0
- package/src/create-host.js +351 -0
- package/src/derived/data-extractor.js +22 -0
- package/src/derived/index.js +121 -0
- package/src/documents/format-html.js +313 -0
- package/src/documents/replace.js +257 -0
- package/src/documents/root-attrs.js +171 -0
- package/src/documents/serve.js +137 -0
- package/src/documents/stale.js +20 -0
- package/src/index.js +12 -0
- package/src/inspect.js +136 -0
- package/src/json-errors.js +59 -0
- package/src/livesync.js +75 -0
- package/src/nodes/identity.js +14 -0
- package/src/nodes/names.js +57 -0
- package/src/nodes/ops.js +613 -0
- package/src/nodes/scanner.js +321 -0
- package/src/nodes/store.js +111 -0
- package/src/pages.js +166 -0
- package/src/paths.js +302 -0
- package/src/recovery/overlay.js +327 -0
- package/src/recovery/replay.js +236 -0
- package/src/recovery-ui.js +185 -0
- package/src/recovery.js +30 -0
- package/src/requests.js +73 -0
- package/src/routes/meta.js +62 -0
- package/src/routes/read.js +105 -0
- package/src/routes/save.js +102 -0
- package/src/routes/sync.js +118 -0
- package/src/routes/upload.js +128 -0
- package/src/share/index.js +207 -0
- package/src/share/save-tokens.js +65 -0
- package/src/spec/codes.js +42 -0
- package/src/spec/meta.js +52 -0
- package/src/spec/wire.js +115 -0
- package/src/store/index.js +29 -0
- package/src/store/migrations/001-init.sql +114 -0
- package/src/store/sqlite.js +540 -0
- package/src/templates.js +50 -0
- package/src/tenants/index.js +355 -0
- package/src/tenants/isolation.js +91 -0
- package/src/tenants/routes.js +131 -0
- package/src/ui.js +95 -0
- package/src/util/cookies.js +26 -0
- package/src/util/express.js +8 -0
- package/src/util/fsx.js +205 -0
- package/src/util/id.js +37 -0
- package/src/util/lockfile.js +52 -0
- package/src/util/locks.js +35 -0
- package/src/util/multipart.js +33 -0
- package/src/versions/files.js +307 -0
- package/src/versions/index.js +16 -0
- package/src/versions/naming.js +172 -0
- package/src/versions/routes.js +91 -0
- package/src/wire-compat.js +61 -0
- package/ui/app.css +164 -0
- package/ui/attic.html +198 -0
- package/ui/dashboard.html +456 -0
- package/ui/editor.html +156 -0
- package/ui/error.html +18 -0
- package/ui/login.html +58 -0
- package/ui/records.html +173 -0
- package/ui/recovery.html +152 -0
- package/ui/setup.html +61 -0
- package/ui/share-qr.html +44 -0
- package/ui/templates/blank.html +16 -0
- package/ui/templates/devlog.html +71 -0
- package/ui/templates/hackable-dashboard.html +145 -0
- package/ui/templates/kanban.html +85 -0
- package/ui/templates/landing.html +108 -0
- package/ui/templates/writer.html +50 -0
- package/ui/tenants.html +172 -0
- package/ui/trash.html +134 -0
- package/ui/versions.html +114 -0
|
@@ -0,0 +1,456 @@
|
|
|
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
|
+
#drop.over { outline: 2px dashed var(--accent); outline-offset: -6px; }
|
|
10
|
+
th.sortable { cursor: pointer; user-select: none; }
|
|
11
|
+
th.sortable[aria-sort="ascending"]::after { content: " ↑"; }
|
|
12
|
+
th.sortable[aria-sort="descending"]::after { content: " ↓"; }
|
|
13
|
+
td.name { max-width: 26rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body data-tenants="{{tenants}}" data-uploads="{{uploads}}" data-shares="{{shares}}" data-collections="{{collections}}">
|
|
17
|
+
|
|
18
|
+
<header class="bar">
|
|
19
|
+
<span class="brand">makerclay</span>
|
|
20
|
+
<nav class="crumbs" id="crumbs"></nav>
|
|
21
|
+
<span class="spacer"></span>
|
|
22
|
+
<input type="search" id="q" placeholder="Search this folder" style="width:12rem" aria-label="Search">
|
|
23
|
+
<button id="new-doc" class="primary">New document</button>
|
|
24
|
+
<button id="new-folder">New folder</button>
|
|
25
|
+
<button id="upload-btn">Upload</button>
|
|
26
|
+
<a class="btn" href="/_/trash.html">Trash</a>
|
|
27
|
+
<a class="btn" href="/_/tenants.html" id="people" hidden>People</a>
|
|
28
|
+
<button id="signout">Sign out</button>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<main class="wrap" id="drop">
|
|
32
|
+
<table>
|
|
33
|
+
<thead>
|
|
34
|
+
<tr>
|
|
35
|
+
<th class="sortable" data-sort="name">Name</th>
|
|
36
|
+
<th class="num sortable" data-sort="size">Size</th>
|
|
37
|
+
<th class="num sortable" data-sort="modified">Modified</th>
|
|
38
|
+
<th class="actions">Actions</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody id="rows"></tbody>
|
|
42
|
+
</table>
|
|
43
|
+
<div class="empty" id="empty" hidden>This folder is empty.</div>
|
|
44
|
+
</main>
|
|
45
|
+
|
|
46
|
+
<input type="file" id="file-input" multiple class="sr">
|
|
47
|
+
|
|
48
|
+
<dialog id="new-dialog">
|
|
49
|
+
<form method="dialog" id="new-form">
|
|
50
|
+
<h2>New document</h2>
|
|
51
|
+
<label for="new-name">Name</label>
|
|
52
|
+
<input type="text" id="new-name" value="untitled.html" required spellcheck="false">
|
|
53
|
+
<label for="new-template">Start from</label>
|
|
54
|
+
<select id="new-template"></select>
|
|
55
|
+
<p class="hint" id="new-note"></p>
|
|
56
|
+
<div class="buttons">
|
|
57
|
+
<button value="cancel" formnovalidate>Cancel</button>
|
|
58
|
+
<button value="create" class="primary">Create</button>
|
|
59
|
+
</div>
|
|
60
|
+
</form>
|
|
61
|
+
</dialog>
|
|
62
|
+
|
|
63
|
+
<dialog id="collect-dialog">
|
|
64
|
+
<form method="dialog">
|
|
65
|
+
<h2>Submissions for <span id="collect-name"></span></h2>
|
|
66
|
+
<p class="hint">This document can now take form posts from anywhere. Put these two
|
|
67
|
+
attributes on your <code><form></code>:</p>
|
|
68
|
+
<pre class="mono" id="collect-snippet" style="white-space:pre-wrap;word-break:break-all;
|
|
69
|
+
background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:.6rem;margin:.5rem 0"></pre>
|
|
70
|
+
<p class="hint">The token only permits posting a record. It reads nothing back, so it is
|
|
71
|
+
safe to ship inside a page you hand out.</p>
|
|
72
|
+
<p class="hint">Address: <code id="collect-slug"></code> · token: <code id="collect-token"></code></p>
|
|
73
|
+
<div class="buttons">
|
|
74
|
+
<button id="collect-copy">Copy the snippet</button>
|
|
75
|
+
<a class="btn" id="collect-records" href="#">See submissions</a>
|
|
76
|
+
<button value="done" class="primary">Done</button>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
</dialog>
|
|
80
|
+
|
|
81
|
+
<div class="toast" id="toast"></div>
|
|
82
|
+
|
|
83
|
+
<script>
|
|
84
|
+
const rows = document.getElementById('rows');
|
|
85
|
+
const crumbs = document.getElementById('crumbs');
|
|
86
|
+
const empty = document.getElementById('empty');
|
|
87
|
+
const toastEl = document.getElementById('toast');
|
|
88
|
+
const canShare = document.body.dataset.shares === 'true';
|
|
89
|
+
const canTenant = document.body.dataset.tenants === 'true';
|
|
90
|
+
document.getElementById('people').hidden = !canTenant;
|
|
91
|
+
const canUpload = document.body.dataset.uploads === 'true';
|
|
92
|
+
const canCollect = document.body.dataset.collections === 'true';
|
|
93
|
+
|
|
94
|
+
let dir = new URLSearchParams(location.search).get('dir') || '';
|
|
95
|
+
let sort = 'name';
|
|
96
|
+
let direction = 'asc';
|
|
97
|
+
let q = '';
|
|
98
|
+
|
|
99
|
+
function toast(message, isError) {
|
|
100
|
+
toastEl.textContent = message;
|
|
101
|
+
toastEl.className = 'toast show' + (isError ? ' error' : '');
|
|
102
|
+
clearTimeout(toast.timer);
|
|
103
|
+
toast.timer = setTimeout(() => { toastEl.className = 'toast'; }, 2600);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function api(path, body, method) {
|
|
107
|
+
const res = await fetch(path, {
|
|
108
|
+
method: method || (body ? 'POST' : 'GET'),
|
|
109
|
+
headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' }
|
|
110
|
+
: { Accept: 'application/json' },
|
|
111
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
112
|
+
});
|
|
113
|
+
const parsed = await res.json().catch(() => ({}));
|
|
114
|
+
if (!res.ok) throw new Error(parsed.msg || res.statusText);
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const when = (ms) => {
|
|
119
|
+
const days = (Date.now() - ms) / 86400000;
|
|
120
|
+
if (days < 1) return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
121
|
+
if (days < 350) return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
122
|
+
return new Date(ms).toLocaleDateString([], { year: 'numeric', month: 'short' });
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function drawCrumbs(list) {
|
|
126
|
+
crumbs.replaceChildren(...list.flatMap((crumb, index) => {
|
|
127
|
+
const link = Object.assign(document.createElement('a'), {
|
|
128
|
+
textContent: crumb.name,
|
|
129
|
+
href: crumb.path ? `/_/?dir=${encodeURIComponent(crumb.path)}` : '/_/',
|
|
130
|
+
});
|
|
131
|
+
link.addEventListener('click', (event) => { event.preventDefault(); go(crumb.path); });
|
|
132
|
+
if (index === list.length - 1) return [link];
|
|
133
|
+
const sep = document.createElement('span');
|
|
134
|
+
sep.appendChild(link);
|
|
135
|
+
return [sep];
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function actionButton(text, title, handler, className) {
|
|
140
|
+
const button = document.createElement('button');
|
|
141
|
+
button.textContent = text;
|
|
142
|
+
button.title = title;
|
|
143
|
+
if (className) button.className = className;
|
|
144
|
+
button.addEventListener('click', handler);
|
|
145
|
+
return button;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function drawRow(entry) {
|
|
149
|
+
const tr = document.createElement('tr');
|
|
150
|
+
|
|
151
|
+
const name = document.createElement('td');
|
|
152
|
+
name.className = 'name';
|
|
153
|
+
const link = document.createElement('a');
|
|
154
|
+
link.textContent = (entry.kind === 'dir' ? '📁 ' : entry.kind === 'doc' ? '📄 ' : '🖇 ') + entry.name;
|
|
155
|
+
link.href = entry.kind === 'dir' ? `/_/?dir=${encodeURIComponent(entry.path)}` : entry.url;
|
|
156
|
+
if (entry.kind === 'dir') {
|
|
157
|
+
link.addEventListener('click', (event) => { event.preventDefault(); go(entry.path); });
|
|
158
|
+
} else {
|
|
159
|
+
link.target = '_blank';
|
|
160
|
+
}
|
|
161
|
+
name.appendChild(link);
|
|
162
|
+
if (entry.private) name.insertAdjacentHTML('beforeend', '<span class="tag private">private</span>');
|
|
163
|
+
if (entry.shared) name.insertAdjacentHTML('beforeend', `<span class="tag shared">${entry.shared} link${entry.shared > 1 ? 's' : ''}</span>`);
|
|
164
|
+
if (entry.isolated) name.insertAdjacentHTML('beforeend', '<span class="tag">sandboxed</span>');
|
|
165
|
+
if (entry.collection) name.insertAdjacentHTML('beforeend', '<span class="tag shared">collecting</span>');
|
|
166
|
+
tr.appendChild(name);
|
|
167
|
+
|
|
168
|
+
const size = document.createElement('td');
|
|
169
|
+
size.className = 'num';
|
|
170
|
+
size.textContent = entry.sizeLabel || '';
|
|
171
|
+
tr.appendChild(size);
|
|
172
|
+
|
|
173
|
+
const modified = document.createElement('td');
|
|
174
|
+
modified.className = 'num';
|
|
175
|
+
modified.textContent = when(entry.modified);
|
|
176
|
+
tr.appendChild(modified);
|
|
177
|
+
|
|
178
|
+
const actions = document.createElement('td');
|
|
179
|
+
actions.className = 'actions';
|
|
180
|
+
if (entry.kind === 'doc') {
|
|
181
|
+
actions.append(
|
|
182
|
+
Object.assign(document.createElement('a'), {
|
|
183
|
+
className: 'btn', textContent: 'Edit', href: `/_/edit/${entry.path.split('/').map(encodeURIComponent).join('/')}`,
|
|
184
|
+
style: 'padding:.2rem .45rem;font-size:.82rem',
|
|
185
|
+
}),
|
|
186
|
+
actionButton('History', 'Version history', () => {
|
|
187
|
+
location.href = `/_/versions.html?path=${encodeURIComponent(entry.path)}`;
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
if (canShare) actions.appendChild(actionButton('Share', 'Create a share link', () => share(entry)));
|
|
191
|
+
actions.appendChild(actionButton(entry.private ? 'Publish' : 'Make private', 'Toggle who can read it',
|
|
192
|
+
() => flag('private', entry, !entry.private)));
|
|
193
|
+
actions.appendChild(actionButton(
|
|
194
|
+
entry.isolated ? 'Leave sandbox' : 'Sandbox',
|
|
195
|
+
entry.isolated
|
|
196
|
+
? 'Run this file normally again'
|
|
197
|
+
: "Run this file in a sandbox: safer, but it can't use browser storage",
|
|
198
|
+
() => sandbox(entry),
|
|
199
|
+
));
|
|
200
|
+
if (canCollect) {
|
|
201
|
+
actions.appendChild(actionButton(
|
|
202
|
+
entry.collection ? 'Submissions' : 'Collect',
|
|
203
|
+
entry.collection
|
|
204
|
+
? 'See what people have sent this document'
|
|
205
|
+
: 'Let this document accept form submissions',
|
|
206
|
+
() => collect(entry),
|
|
207
|
+
));
|
|
208
|
+
}
|
|
209
|
+
if (canTenant) {
|
|
210
|
+
actions.appendChild(actionButton(
|
|
211
|
+
entry.signups ? 'Close signups' : 'Open signups',
|
|
212
|
+
'Let other people take their own copy of this document',
|
|
213
|
+
() => flag('signups', entry, !entry.signups),
|
|
214
|
+
));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
actions.append(
|
|
218
|
+
actionButton('Rename', 'Rename', () => rename(entry)),
|
|
219
|
+
actionButton('Copy', 'Duplicate', () => run(() => api('/_/copy', { path: entry.path }), 'Copied')),
|
|
220
|
+
actionButton('Delete', 'Move to trash', () => run(() => api('/_/delete', { paths: [entry.path] }), 'Moved to trash'), 'danger'),
|
|
221
|
+
);
|
|
222
|
+
tr.appendChild(actions);
|
|
223
|
+
return tr;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function load() {
|
|
227
|
+
const params = new URLSearchParams({ dir, sort, direction });
|
|
228
|
+
if (q) params.set('q', q);
|
|
229
|
+
try {
|
|
230
|
+
const body = await api(`/_/nodes?${params}`);
|
|
231
|
+
drawCrumbs(body.breadcrumbs);
|
|
232
|
+
rows.replaceChildren(...body.entries.map(drawRow));
|
|
233
|
+
empty.hidden = body.entries.length > 0;
|
|
234
|
+
document.querySelectorAll('th.sortable').forEach((th) => {
|
|
235
|
+
th.setAttribute('aria-sort', th.dataset.sort === sort
|
|
236
|
+
? (direction === 'asc' ? 'ascending' : 'descending') : 'none');
|
|
237
|
+
});
|
|
238
|
+
} catch (problem) {
|
|
239
|
+
toast(problem.message, true);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function go(next) {
|
|
244
|
+
dir = next || '';
|
|
245
|
+
const url = dir ? `/_/?dir=${encodeURIComponent(dir)}` : '/_/';
|
|
246
|
+
history.pushState({ dir }, '', url);
|
|
247
|
+
load();
|
|
248
|
+
}
|
|
249
|
+
addEventListener('popstate', (event) => { dir = event.state?.dir || ''; load(); });
|
|
250
|
+
|
|
251
|
+
async function run(work, message) {
|
|
252
|
+
try {
|
|
253
|
+
await work();
|
|
254
|
+
if (message) toast(message);
|
|
255
|
+
load();
|
|
256
|
+
} catch (problem) {
|
|
257
|
+
toast(problem.message, true);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function rename(entry) {
|
|
262
|
+
const name = prompt(`Rename “${entry.name}” to:`, entry.name);
|
|
263
|
+
if (!name || name === entry.name) return;
|
|
264
|
+
run(() => api('/_/rename', { path: entry.path, name }), 'Renamed');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const FLAG_MESSAGES = {
|
|
268
|
+
private: ['Now private', 'Now public'],
|
|
269
|
+
signups: ['Anyone with the link can take their own copy', 'Signups closed'],
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function flag(kind, entry, value) {
|
|
273
|
+
const [on, off] = FLAG_MESSAGES[kind] || ['Done', 'Done'];
|
|
274
|
+
run(() => api(`/_/${kind}`, { path: entry.path, value }), value ? on : off);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The sandbox costs the document browser storage, so the server greps for it and
|
|
278
|
+
// says so rather than letting the owner find out from a broken app.
|
|
279
|
+
async function sandbox(entry) {
|
|
280
|
+
const value = !entry.isolated;
|
|
281
|
+
try {
|
|
282
|
+
const body = await api('/_/isolate', { path: entry.path, value });
|
|
283
|
+
if (body.warning) toast(body.warning, true);
|
|
284
|
+
else toast(value ? 'Now running in a sandbox' : 'No longer sandboxed');
|
|
285
|
+
load();
|
|
286
|
+
} catch (problem) {
|
|
287
|
+
toast(problem.message, true);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function share(entry) {
|
|
292
|
+
const grant = confirm(`Let people EDIT “${entry.name}” with this link?\n\nOK = edit, Cancel = view only`)
|
|
293
|
+
? 'edit' : 'view';
|
|
294
|
+
try {
|
|
295
|
+
const body = await api('/_/share', { path: entry.path, grant });
|
|
296
|
+
await navigator.clipboard.writeText(body.url).catch(() => {});
|
|
297
|
+
toast('Link copied. Opening the QR page.');
|
|
298
|
+
window.open(body.qrUrl, '_blank');
|
|
299
|
+
load();
|
|
300
|
+
} catch (problem) {
|
|
301
|
+
toast(problem.message, true);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Marking is idempotent: the server hands back the existing collection if this
|
|
306
|
+
// document already is one, so the same click opens the panel either way.
|
|
307
|
+
async function collect(entry) {
|
|
308
|
+
try {
|
|
309
|
+
const body = await api('/_/collection', { path: entry.path });
|
|
310
|
+
const dialog = document.getElementById('collect-dialog');
|
|
311
|
+
document.getElementById('collect-name').textContent = entry.name;
|
|
312
|
+
document.getElementById('collect-slug').textContent = body.slug;
|
|
313
|
+
document.getElementById('collect-token').textContent = body.submitToken;
|
|
314
|
+
document.getElementById('collect-records').href = `/_/records.html?slug=${encodeURIComponent(body.slug)}`;
|
|
315
|
+
document.getElementById('collect-snippet').textContent =
|
|
316
|
+
`<form collection="${body.slug}" collection-token="${body.submitToken}">`;
|
|
317
|
+
dialog.returnValue = '';
|
|
318
|
+
dialog.showModal();
|
|
319
|
+
if (!entry.collection) load();
|
|
320
|
+
} catch (problem) {
|
|
321
|
+
toast(problem.message, true);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
document.getElementById('collect-copy').addEventListener('click', async (event) => {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
await navigator.clipboard.writeText(document.getElementById('collect-snippet').textContent).catch(() => {});
|
|
328
|
+
toast('Copied');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const newDialog = document.getElementById('new-dialog');
|
|
332
|
+
const newName = document.getElementById('new-name');
|
|
333
|
+
const newTemplate = document.getElementById('new-template');
|
|
334
|
+
const newNote = document.getElementById('new-note');
|
|
335
|
+
|
|
336
|
+
// Notes for the templates that ship. A starter you dropped into ui/templates
|
|
337
|
+
// yourself still shows up in this list, just without a description.
|
|
338
|
+
const TEMPLATE_NOTES = {
|
|
339
|
+
blank: 'A heading, a paragraph, and nothing else.',
|
|
340
|
+
writer: 'One long editable article with a live word count.',
|
|
341
|
+
kanban: 'Draggable cards in three columns.',
|
|
342
|
+
devlog: 'Dated entries, newest first.',
|
|
343
|
+
landing: 'A pitch, three features, and a sign-up form you can point at a collection.',
|
|
344
|
+
'hackable-dashboard': 'This file manager, as a document you own and can rewrite.',
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
async function fillTemplates() {
|
|
348
|
+
if (newTemplate.options.length) return;
|
|
349
|
+
let names = ['blank'];
|
|
350
|
+
try {
|
|
351
|
+
names = (await api('/_/templates')).templates;
|
|
352
|
+
} catch (problem) {
|
|
353
|
+
toast(problem.message, true);
|
|
354
|
+
}
|
|
355
|
+
newTemplate.replaceChildren(...names.map((name) => Object.assign(
|
|
356
|
+
document.createElement('option'), { value: name, textContent: name },
|
|
357
|
+
)));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const describeTemplate = () => {
|
|
361
|
+
newNote.textContent = TEMPLATE_NOTES[newTemplate.value] || '';
|
|
362
|
+
};
|
|
363
|
+
newTemplate.addEventListener('change', describeTemplate);
|
|
364
|
+
|
|
365
|
+
document.getElementById('new-doc').addEventListener('click', async () => {
|
|
366
|
+
await fillTemplates();
|
|
367
|
+
describeTemplate();
|
|
368
|
+
newName.value = 'untitled.html';
|
|
369
|
+
newDialog.showModal();
|
|
370
|
+
newName.setSelectionRange(0, newName.value.lastIndexOf('.'));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
newDialog.addEventListener('close', () => {
|
|
374
|
+
const name = newName.value.trim();
|
|
375
|
+
if (newDialog.returnValue !== 'create' || !name) return;
|
|
376
|
+
run(async () => {
|
|
377
|
+
const body = await api('/_/new', {
|
|
378
|
+
dir, name: name.endsWith('.html') ? name : `${name}.html`, template: newTemplate.value,
|
|
379
|
+
});
|
|
380
|
+
location.href = `/_/edit/${body.path.split('/').map(encodeURIComponent).join('/')}`;
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
document.getElementById('new-folder').addEventListener('click', () => {
|
|
385
|
+
const name = prompt('Name your folder:');
|
|
386
|
+
if (name) run(() => api('/_/mkdir', { dir, name }), 'Folder created');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const fileInput = document.getElementById('file-input');
|
|
390
|
+
document.getElementById('upload-btn').addEventListener('click', () => fileInput.click());
|
|
391
|
+
document.getElementById('upload-btn').hidden = !canUpload;
|
|
392
|
+
fileInput.addEventListener('change', () => upload([...fileInput.files]));
|
|
393
|
+
|
|
394
|
+
async function upload(files) {
|
|
395
|
+
if (!files.length) return;
|
|
396
|
+
const form = new FormData();
|
|
397
|
+
form.append('dir', dir);
|
|
398
|
+
for (const file of files) form.append('file', file, file.name);
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch('/_/add-files', { method: 'POST', body: form, headers: { Accept: 'application/json' } });
|
|
401
|
+
const body = await res.json().catch(() => ({}));
|
|
402
|
+
if (!res.ok) throw new Error(body.msg || 'Upload failed');
|
|
403
|
+
toast(`Uploaded ${body.files.length} file${body.files.length > 1 ? 's' : ''}`);
|
|
404
|
+
load();
|
|
405
|
+
} catch (problem) {
|
|
406
|
+
toast(problem.message, true);
|
|
407
|
+
} finally {
|
|
408
|
+
fileInput.value = '';
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const drop = document.getElementById('drop');
|
|
413
|
+
['dragenter', 'dragover'].forEach((name) => drop.addEventListener(name, (event) => {
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
if (canUpload) drop.classList.add('over');
|
|
416
|
+
}));
|
|
417
|
+
['dragleave', 'drop'].forEach((name) => drop.addEventListener(name, () => drop.classList.remove('over')));
|
|
418
|
+
drop.addEventListener('drop', (event) => {
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
if (canUpload) upload([...event.dataTransfer.files]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
document.getElementById('q').addEventListener('input', (event) => {
|
|
424
|
+
q = event.target.value;
|
|
425
|
+
clearTimeout(load.timer);
|
|
426
|
+
load.timer = setTimeout(load, 160);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
document.querySelectorAll('th.sortable').forEach((th) => th.addEventListener('click', () => {
|
|
430
|
+
if (sort === th.dataset.sort) direction = direction === 'asc' ? 'desc' : 'asc';
|
|
431
|
+
else { sort = th.dataset.sort; direction = 'asc'; }
|
|
432
|
+
load();
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
document.getElementById('signout').addEventListener('click', async () => {
|
|
436
|
+
await fetch('/_/logout', { method: 'POST', headers: { Accept: 'application/json' } });
|
|
437
|
+
location.href = '/_/login';
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Only the table body is redrawn, so a rename in a terminal shows up here
|
|
441
|
+
// without stealing focus from the search box or a half-typed prompt.
|
|
442
|
+
const events = new EventSource('/_/events');
|
|
443
|
+
let pending = null;
|
|
444
|
+
for (const name of ['node-saved', 'node-created', 'node-moved', 'node-trashed',
|
|
445
|
+
'node-restored', 'node-purged', 'node-updated', 'node-changed',
|
|
446
|
+
'node-renamed', 'node-appeared']) {
|
|
447
|
+
events.addEventListener(name, () => {
|
|
448
|
+
clearTimeout(pending);
|
|
449
|
+
pending = setTimeout(load, 120);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
load();
|
|
454
|
+
</script>
|
|
455
|
+
</body>
|
|
456
|
+
</html>
|
package/ui/editor.html
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
<link rel="stylesheet" href="/_/editor/lib/codemirror.css">
|
|
9
|
+
<link rel="stylesheet" href="/_/editor/addon/dialog/dialog.css">
|
|
10
|
+
<style>
|
|
11
|
+
html, body { height: 100%; }
|
|
12
|
+
body { display: flex; flex-direction: column; }
|
|
13
|
+
.CodeMirror { flex: 1; height: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13.5px; }
|
|
14
|
+
@media (prefers-color-scheme: dark) {
|
|
15
|
+
.CodeMirror { background: var(--panel); color: var(--ink); }
|
|
16
|
+
.CodeMirror-gutters { background: var(--panel); border-color: var(--line); }
|
|
17
|
+
.CodeMirror-cursor { border-left-color: var(--ink); }
|
|
18
|
+
/* CodeMirror 5 ships one light theme, so the two highlights it paints in
|
|
19
|
+
pale blue have to be restated or they glare on a dark page. */
|
|
20
|
+
.CodeMirror-activeline-background { background: color-mix(in oklab, var(--accent) 10%, transparent); }
|
|
21
|
+
.CodeMirror-selected { background: color-mix(in oklab, var(--accent) 22%, transparent) !important; }
|
|
22
|
+
.CodeMirror-matchingbracket { color: var(--accent) !important; outline: 1px solid var(--line); }
|
|
23
|
+
.cm-s-default .cm-tag { color: #d98b4a; }
|
|
24
|
+
.cm-s-default .cm-attribute { color: #9bbd7b; }
|
|
25
|
+
.cm-s-default .cm-string { color: #d3b58d; }
|
|
26
|
+
.cm-s-default .cm-comment { color: var(--muted); }
|
|
27
|
+
.cm-s-default .cm-keyword { color: #c58fd0; }
|
|
28
|
+
}
|
|
29
|
+
#status { color: var(--muted); font-size: .86rem; }
|
|
30
|
+
#status.dirty { color: var(--accent); }
|
|
31
|
+
#status.error { color: var(--danger); }
|
|
32
|
+
</style>
|
|
33
|
+
</head>
|
|
34
|
+
<body data-path="{{path}}" data-etag="{{etag}}" data-mode="{{mode}}" data-save-url="{{saveUrl}}">
|
|
35
|
+
|
|
36
|
+
<header class="bar">
|
|
37
|
+
<a class="btn" href="/_/" title="Back to your files">←</a>
|
|
38
|
+
<span class="brand">{{name}}</span>
|
|
39
|
+
<span id="status">Saved</span>
|
|
40
|
+
<span class="spacer"></span>
|
|
41
|
+
<a class="btn" href="/{{path}}" target="_blank">View</a>
|
|
42
|
+
<a class="btn" href="/_/versions.html?path={{path}}">History</a>
|
|
43
|
+
<button id="save" class="primary">Save</button>
|
|
44
|
+
</header>
|
|
45
|
+
|
|
46
|
+
<textarea id="source" class="sr">{{source}}</textarea>
|
|
47
|
+
|
|
48
|
+
<div class="toast" id="toast"></div>
|
|
49
|
+
|
|
50
|
+
<script src="/_/editor/lib/codemirror.js"></script>
|
|
51
|
+
<script src="/_/editor/mode/xml/xml.js"></script>
|
|
52
|
+
<script src="/_/editor/mode/javascript/javascript.js"></script>
|
|
53
|
+
<script src="/_/editor/mode/css/css.js"></script>
|
|
54
|
+
<script src="/_/editor/mode/htmlmixed/htmlmixed.js"></script>
|
|
55
|
+
<script src="/_/editor/addon/edit/closetag.js"></script>
|
|
56
|
+
<script src="/_/editor/addon/edit/matchbrackets.js"></script>
|
|
57
|
+
<script src="/_/editor/addon/selection/active-line.js"></script>
|
|
58
|
+
<script src="/_/editor/addon/dialog/dialog.js"></script>
|
|
59
|
+
<script src="/_/editor/addon/search/searchcursor.js"></script>
|
|
60
|
+
<script src="/_/editor/addon/search/search.js"></script>
|
|
61
|
+
<script src="/_/editor/addon/search/jump-to-line.js"></script>
|
|
62
|
+
|
|
63
|
+
<script>
|
|
64
|
+
const docPath = document.body.dataset.path;
|
|
65
|
+
const statusEl = document.getElementById('status');
|
|
66
|
+
const toastEl = document.getElementById('toast');
|
|
67
|
+
// The etag the page loaded with. It travels back as If-Match, which makes this
|
|
68
|
+
// the first conditional-save client in the ecosystem: a second tab cannot
|
|
69
|
+
// silently overwrite what this one is looking at.
|
|
70
|
+
let etag = document.body.dataset.etag;
|
|
71
|
+
let dirty = false;
|
|
72
|
+
|
|
73
|
+
const editor = CodeMirror.fromTextArea(document.getElementById('source'), {
|
|
74
|
+
mode: document.body.dataset.mode,
|
|
75
|
+
lineNumbers: true,
|
|
76
|
+
lineWrapping: true,
|
|
77
|
+
autoCloseTags: true,
|
|
78
|
+
matchBrackets: true,
|
|
79
|
+
styleActiveLine: true,
|
|
80
|
+
indentUnit: 2,
|
|
81
|
+
tabSize: 2,
|
|
82
|
+
viewportMargin: 40,
|
|
83
|
+
});
|
|
84
|
+
editor.focus();
|
|
85
|
+
|
|
86
|
+
function toast(message, isError) {
|
|
87
|
+
toastEl.textContent = message;
|
|
88
|
+
toastEl.className = 'toast show' + (isError ? ' error' : '');
|
|
89
|
+
clearTimeout(toast.timer);
|
|
90
|
+
toast.timer = setTimeout(() => { toastEl.className = 'toast'; }, 3200);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setStatus(text, kind) {
|
|
94
|
+
statusEl.textContent = text;
|
|
95
|
+
statusEl.className = kind || '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
editor.on('change', () => {
|
|
99
|
+
if (dirty) return;
|
|
100
|
+
dirty = true;
|
|
101
|
+
setStatus('Unsaved changes', 'dirty');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
async function save() {
|
|
105
|
+
const button = document.getElementById('save');
|
|
106
|
+
button.disabled = true;
|
|
107
|
+
setStatus('Saving…');
|
|
108
|
+
try {
|
|
109
|
+
// A sandboxed document saves through its token, so the server decides which
|
|
110
|
+
// lane this page uses rather than the page assuming the cookie one.
|
|
111
|
+
const res = await fetch(document.body.dataset.saveUrl, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
115
|
+
'Document-URL': '/' + docPath,
|
|
116
|
+
'If-Match': '"' + etag + '"',
|
|
117
|
+
'Save-Trigger': 'user',
|
|
118
|
+
Accept: 'application/json',
|
|
119
|
+
},
|
|
120
|
+
body: editor.getValue(),
|
|
121
|
+
});
|
|
122
|
+
const body = await res.json().catch(() => ({}));
|
|
123
|
+
|
|
124
|
+
if (res.status === 412) {
|
|
125
|
+
setStatus('Someone else changed this file', 'error');
|
|
126
|
+
toast('This file changed since you opened it. Open History to compare before you overwrite.', true);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!res.ok) throw new Error(body.msg || 'Save failed');
|
|
130
|
+
|
|
131
|
+
etag = body.etag || etag;
|
|
132
|
+
dirty = false;
|
|
133
|
+
setStatus('Saved');
|
|
134
|
+
if (body.msgType === 'warning') toast(body.msg, true);
|
|
135
|
+
} catch (problem) {
|
|
136
|
+
setStatus('Not saved', 'error');
|
|
137
|
+
toast(problem.message, true);
|
|
138
|
+
} finally {
|
|
139
|
+
button.disabled = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
document.getElementById('save').addEventListener('click', save);
|
|
144
|
+
editor.setOption('extraKeys', {
|
|
145
|
+
'Cmd-S': save,
|
|
146
|
+
'Ctrl-S': save,
|
|
147
|
+
});
|
|
148
|
+
addEventListener('keydown', (event) => {
|
|
149
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); save(); }
|
|
150
|
+
});
|
|
151
|
+
addEventListener('beforeunload', (event) => {
|
|
152
|
+
if (dirty) { event.preventDefault(); event.returnValue = ''; }
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|
package/ui/error.html
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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 .5rem;font-size:1.3rem">{{title}}</h1>
|
|
13
|
+
<p style="margin:0">{{message}}</p>
|
|
14
|
+
<p class="hint" style="margin-top:1.2rem"><a href="/_/">Back to your files</a></p>
|
|
15
|
+
</div>
|
|
16
|
+
</main>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|