@lovek4d/kickflip 0.0.0-stage → 0.1.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/CONTEXT.md +414 -0
- package/LICENSE +57 -0
- package/README.md +23 -2
- package/SKILL.md +121 -0
- package/bin/doctor.mjs +3 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +125 -0
- package/bin/statusline.mjs +4 -0
- package/bin/wait-events.mjs +3 -0
- package/bin/web-ts.mjs +2 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +207 -0
- package/board/bin/builder-stop-hook.mjs +5 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +3 -0
- package/board/public/app-asks.js +169 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +200 -0
- package/board/public/app-core.js +96 -0
- package/board/public/app-header.js +108 -0
- package/board/public/app-lanes.js +92 -0
- package/board/public/app-needs.js +30 -0
- package/board/public/app-snapshot.js +97 -0
- package/board/public/app-threads.js +165 -0
- package/board/public/app.html +83 -0
- package/board/public/app.js +327 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +360 -0
- package/board/public/hub-kit.js +151 -0
- package/board/public/hub.css +164 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +223 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +199 -0
- package/board/public/sidebar.js +287 -0
- package/board/public/style.css +570 -0
- package/board/public/toasts.js +179 -0
- package/board/public/ui-cards.js +153 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +84 -0
- package/board/public/ui-hub.js +94 -0
- package/board/public/ui-links.js +142 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-page-header.js +63 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +130 -0
- package/board/public/ui-sidebar.js +177 -0
- package/board/public/ui-snapshot.js +56 -0
- package/board/public/ui-threads.js +81 -0
- package/board/public/ui.js +235 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +266 -0
- package/board/public/view-hub.js +99 -0
- package/board/public/view-notes.js +29 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +391 -0
- package/board/public/view-settings.js +328 -0
- package/board/server.mjs +12 -0
- package/board/store.mjs +7 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +14 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +5 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +17 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/repos.mjs +2 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +1 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/try.mjs +3 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +13 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +67 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
|
+
BoardView.part((B) => {
|
|
3
|
+
const { $, store, state, toast, quieted, sendDraft, links } = B;
|
|
4
|
+
const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
|
|
5
|
+
|
|
6
|
+
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
7
|
+
|
|
8
|
+
// The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
|
|
9
|
+
const sendingDone = new Set();
|
|
10
|
+
function syncDone(el, key) {
|
|
11
|
+
const send = el.querySelector('form button[type=submit]');
|
|
12
|
+
if (!send) return;
|
|
13
|
+
const html = doneButton(state.board?.items.find((i) => i.key === key), sendingDone.has(key));
|
|
14
|
+
const current = el.querySelector('form [data-confirm="finish"]');
|
|
15
|
+
if (current?.outerHTML === html) return;
|
|
16
|
+
current?.remove();
|
|
17
|
+
if (html) send.insertAdjacentHTML('beforebegin', html);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
21
|
+
// thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed.
|
|
22
|
+
const seenBefore = new Map(Object.entries(B.seen));
|
|
23
|
+
const moreTaps = new Map();
|
|
24
|
+
const firstDrawn = new Map();
|
|
25
|
+
const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
|
|
26
|
+
const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
|
|
27
|
+
B.on(B.root, 'toggle', (ev) => {
|
|
28
|
+
const el = ev.target;
|
|
29
|
+
if (!el.matches?.('details.item') || el.open) return;
|
|
30
|
+
seenBefore.set(el.dataset.key, B.seen[el.dataset.key]);
|
|
31
|
+
moreTaps.delete(el.dataset.key);
|
|
32
|
+
firstDrawn.delete(el.dataset.key);
|
|
33
|
+
}, true);
|
|
34
|
+
B.on(B.root, 'click', (ev) => {
|
|
35
|
+
const key = ev.target.closest('[data-thread-more]')?.closest('[data-thread]')?.dataset.thread;
|
|
36
|
+
if (key == null) return;
|
|
37
|
+
moreTaps.set(key, (moreTaps.get(key) || 0) + 1);
|
|
38
|
+
B.repaintThread(key);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const editsIn = (key) => new Map([...state.edits].filter(([, e]) => e.thread === key));
|
|
42
|
+
function renderThread(key, msgs) {
|
|
43
|
+
const el = $(`[data-thread="${CSS.escape(key)}"]`);
|
|
44
|
+
if (!el) return;
|
|
45
|
+
syncDone(el, key);
|
|
46
|
+
const edits = editsIn(key);
|
|
47
|
+
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
48
|
+
const view = viewOf(key);
|
|
49
|
+
const sig = JSON.stringify([msgs, [...edits.keys()], view]);
|
|
50
|
+
if (el.dataset.sig === sig) return;
|
|
51
|
+
el.dataset.sig = sig;
|
|
52
|
+
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
53
|
+
if (!el.querySelector('.msgs')) {
|
|
54
|
+
const draft = store.get('draft:' + key, '');
|
|
55
|
+
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"><textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>
|
|
56
|
+
<div class="pending-files" data-files></div>
|
|
57
|
+
<div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
|
|
58
|
+
BoardAttach.paint('item:' + key, B.site.root);
|
|
59
|
+
}
|
|
60
|
+
syncDone(el, key);
|
|
61
|
+
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
|
|
62
|
+
B.flashChanged?.(el);
|
|
63
|
+
// A thread's request is answered by a later thread line, so only a pending one changes the eye.
|
|
64
|
+
const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
|
|
65
|
+
const proto = req && (req.state === 'waiting' || req.state === 'working') ? { pending: req.eventId } : null;
|
|
66
|
+
const pb = el.querySelector('form [data-proto]');
|
|
67
|
+
if (pb && pb.dataset.protoPending !== proto?.pending) pb.outerHTML = protoButton(key, proto);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
71
|
+
// starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
|
|
72
|
+
// (app.html BoardBoot). A response older than the one shown is dropped. A failed load keeps the previous render.
|
|
73
|
+
const inflight = new Map();
|
|
74
|
+
const loadedAt = new Map();
|
|
75
|
+
const shown = new Map();
|
|
76
|
+
let seq = 0;
|
|
77
|
+
function loadThread(key, { force = false, maxAge = 0, from = null } = {}) {
|
|
78
|
+
if (!force && inflight.has(key)) return inflight.get(key);
|
|
79
|
+
if (maxAge && Date.now() - (loadedAt.get(key) || 0) < maxAge) return Promise.resolve();
|
|
80
|
+
const n = ++seq;
|
|
81
|
+
const p = (from || fetch(B.site.url('api/thread?key=' + encodeURIComponent(key)), { cache: 'no-store' })
|
|
82
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`thread ${r.status}`)))))
|
|
83
|
+
.then((msgs) => {
|
|
84
|
+
if ((shown.get(key) || 0) > n) return;
|
|
85
|
+
shown.set(key, n);
|
|
86
|
+
loadedAt.set(key, Date.now());
|
|
87
|
+
state.threadCache.set(key, msgs);
|
|
88
|
+
renderThread(key, msgs);
|
|
89
|
+
B.saveSnapshotSoon?.();
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {})
|
|
92
|
+
.finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
|
|
93
|
+
inflight.set(key, p);
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
B.on(B.root, 'input', (ev) => {
|
|
98
|
+
const f = ev.target.closest('form[data-reply]');
|
|
99
|
+
if (!f || ev.target.tagName !== 'TEXTAREA' || quieted('draft:' + f.dataset.reply, ev.target)) return;
|
|
100
|
+
store.set('draft:' + f.dataset.reply, ev.target.value);
|
|
101
|
+
});
|
|
102
|
+
B.on(B.root, 'click', async (ev) => {
|
|
103
|
+
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
104
|
+
if (!b) return;
|
|
105
|
+
const key = b.closest('form').dataset.reply;
|
|
106
|
+
b.disabled = true;
|
|
107
|
+
try { await B.site.send('item-message', { key, text: b.dataset.quick }); loadThread(key, { force: true }); }
|
|
108
|
+
catch { toast('Could not send'); }
|
|
109
|
+
b.disabled = false;
|
|
110
|
+
});
|
|
111
|
+
B.on(B.root, 'click', async (ev) => {
|
|
112
|
+
const b = ev.target.closest('form[data-reply] button[data-proto]');
|
|
113
|
+
if (!b) return;
|
|
114
|
+
const key = b.dataset.proto;
|
|
115
|
+
const pending = b.dataset.protoPending;
|
|
116
|
+
if (pending) {
|
|
117
|
+
let latest = null;
|
|
118
|
+
try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
|
|
119
|
+
const n = protoNotice(pending, latest);
|
|
120
|
+
if (!n.bump) { B.notice(n.text); return; }
|
|
121
|
+
if (!(await B.confirm(n.text))) return;
|
|
122
|
+
try { await B.site.send('ask-bump', { id: pending }); toast('Bumped to the top'); } catch { toast('Could not bump'); }
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
|
|
126
|
+
b.disabled = true;
|
|
127
|
+
try {
|
|
128
|
+
await B.site.send('prototype-request', { key });
|
|
129
|
+
b.title = b.ariaLabel = 'Prototypes asked for';
|
|
130
|
+
toast('Asked Claude for prototypes');
|
|
131
|
+
loadThread(key, { force: true });
|
|
132
|
+
} catch { b.disabled = false; toast('Could not send'); }
|
|
133
|
+
});
|
|
134
|
+
B.on(B.root, 'submit', async (ev) => {
|
|
135
|
+
const f = ev.target.closest('form[data-reply]');
|
|
136
|
+
if (!f) return;
|
|
137
|
+
ev.preventDefault();
|
|
138
|
+
const ta = f.querySelector('textarea');
|
|
139
|
+
ta.blur();
|
|
140
|
+
const text = ta.value.trim();
|
|
141
|
+
const id = f.dataset.composer;
|
|
142
|
+
const files = BoardAttach.files(id, B.site.root);
|
|
143
|
+
if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
144
|
+
const key = f.dataset.reply;
|
|
145
|
+
const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
|
|
146
|
+
try {
|
|
147
|
+
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
148
|
+
() => B.site.send('item-message', { key, text, ...(files.length ? { files } : {}) }));
|
|
149
|
+
BoardAttach.clear(id, B.site.root);
|
|
150
|
+
btn.disabled = false;
|
|
151
|
+
loadThread(f.dataset.reply, { force: true });
|
|
152
|
+
} catch { B.notice('Could not send. Is the host awake?'); btn.disabled = false; }
|
|
153
|
+
});
|
|
154
|
+
// Send waits while a composer's files upload, the ask's included (attach.js attach-change).
|
|
155
|
+
B.on(B.root, 'attach-change', (ev) => {
|
|
156
|
+
const b = ev.target.querySelector('button[type=submit]');
|
|
157
|
+
if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
Object.assign(B, {
|
|
161
|
+
thread, renderThread, loadThread, sendingDone,
|
|
162
|
+
repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
|
|
163
|
+
syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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,viewport-fit=cover">
|
|
6
|
+
<title>Claude</title>
|
|
7
|
+
<script>
|
|
8
|
+
// The board this URL opens, or `/`'s last board, starts loading with its open cards' threads here, beside the scripts
|
|
9
|
+
// below; app.js loadBoard takes the loads over when it mounts that board. A board's other views load their own, and a
|
|
10
|
+
// site this device's last sidebar copy lists as no board loads nothing here. Above the stylesheets, since a script
|
|
11
|
+
// after one waits for it to load.
|
|
12
|
+
window.BoardBoot = (function () {
|
|
13
|
+
if (!/^\/(?:[^/]+\/)?$/.test(location.pathname)) return null;
|
|
14
|
+
let route = null;
|
|
15
|
+
try { route = decodeURIComponent(location.pathname.split('/')[1] || '') || localStorage.getItem('hub:lastBoard'); } catch {}
|
|
16
|
+
if (!route || route === '_hub') return null;
|
|
17
|
+
try { if (JSON.parse(localStorage.getItem('hub:sidebar') || 'null')?.sites?.some((s) => s.route === route && !s.board)) return null; } catch {}
|
|
18
|
+
const base = '/' + encodeURIComponent(route) + '/';
|
|
19
|
+
const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))); p.catch(() => {}); return p; };
|
|
20
|
+
let open = [];
|
|
21
|
+
try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
|
|
22
|
+
const focus = /^#item=([^&]+)/.exec(location.hash);
|
|
23
|
+
if (focus) open.push(decodeURIComponent(focus[1]));
|
|
24
|
+
return { route, board: get('api/board'), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
|
|
25
|
+
})();
|
|
26
|
+
</script>
|
|
27
|
+
<script>try { if (localStorage.getItem('hub:sidebarCollapsed') === '1') document.documentElement.classList.add('sb-collapsed'); } catch {}</script>
|
|
28
|
+
<link rel="stylesheet" href="/_hub/colors.css?v={{colorsVersion}}">
|
|
29
|
+
<link rel="stylesheet" href="/_hub/app/style.css">
|
|
30
|
+
<link rel="stylesheet" href="/_hub/app/hub.css">
|
|
31
|
+
<link rel="stylesheet" href="/_hub/app/sidebar.css">
|
|
32
|
+
<link rel="icon" type="image/png" href="/_hub/kickflip.png">
|
|
33
|
+
<link rel="apple-touch-icon" href="{{touchIcon}}">
|
|
34
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
35
|
+
<meta name="apple-mobile-web-app-title" content="Claude">
|
|
36
|
+
</head>
|
|
37
|
+
<body class="has-sidebar">
|
|
38
|
+
<aside id="sidebar" class="sidebar" aria-label="Boards">{{sidebar}}</aside>
|
|
39
|
+
<div id="sidebarScrim" class="sidebar-scrim"></div>
|
|
40
|
+
<script id="sidebarData" type="application/json">{{sidebarData}}</script>
|
|
41
|
+
<script src="/_hub/app/ui.js"></script>
|
|
42
|
+
<script src="/_hub/app/ui-links.js"></script>
|
|
43
|
+
<script src="/_hub/app/ui-enums.js"></script>
|
|
44
|
+
<script src="/_hub/app/ui-threads.js"></script>
|
|
45
|
+
<script src="/_hub/app/ui-cards.js"></script>
|
|
46
|
+
<script src="/_hub/app/ui-header.js"></script>
|
|
47
|
+
<script src="/_hub/app/ui-snapshot.js"></script>
|
|
48
|
+
<script src="/_hub/app/ui-page-header.js"></script>
|
|
49
|
+
<script src="/_hub/app/ui-sidebar.js"></script>
|
|
50
|
+
<script src="/_hub/app/ui-questions.js"></script>
|
|
51
|
+
<script src="/_hub/app/ui-prompts.js"></script>
|
|
52
|
+
<script src="/_hub/app/ui-markdown.js"></script>
|
|
53
|
+
<script src="/_hub/app/ui-hub.js"></script>
|
|
54
|
+
<script src="/_hub/app/toasts.js"></script>
|
|
55
|
+
<script src="/_hub/app/dialogs.js"></script>
|
|
56
|
+
<script src="/_hub/app/prompts.js"></script>
|
|
57
|
+
<script src="/_hub/app/rules-editor.js"></script>
|
|
58
|
+
<script src="/_hub/app/claude.js"></script>
|
|
59
|
+
<script src="/_hub/app/attach.js"></script>
|
|
60
|
+
<script src="/_hub/app/hub-kit.js"></script>
|
|
61
|
+
<script src="/_hub/app/sidebar.js"></script>
|
|
62
|
+
<script src="/_hub/app/app.js"></script>
|
|
63
|
+
<script src="/_hub/app/app-core.js"></script>
|
|
64
|
+
<script src="/_hub/app/app-threads.js"></script>
|
|
65
|
+
<script src="/_hub/app/app-cards.js"></script>
|
|
66
|
+
<script src="/_hub/app/app-lanes.js"></script>
|
|
67
|
+
<script src="/_hub/app/app-asks.js"></script>
|
|
68
|
+
<script src="/_hub/app/app-needs.js"></script>
|
|
69
|
+
<script src="/_hub/app/app-header.js"></script>
|
|
70
|
+
<script src="/_hub/app/app-banner.js"></script>
|
|
71
|
+
<script src="/_hub/app/app-snapshot.js"></script>
|
|
72
|
+
<script src="/_hub/app/view-settings.js"></script>
|
|
73
|
+
<script src="/_hub/app/view-questions.js"></script>
|
|
74
|
+
<script src="/_hub/app/view-notes.js"></script>
|
|
75
|
+
<script src="/_hub/app/view-hub.js"></script>
|
|
76
|
+
<script src="/_hub/app/view-hub-settings.js"></script>
|
|
77
|
+
<script src="/_hub/app/view-polling.js"></script>
|
|
78
|
+
<script src="/_hub/app/view-frame.js"></script>
|
|
79
|
+
<script src="/_hub/app/router.js"></script>
|
|
80
|
+
<script src="/_hub/app/shell.js"></script>
|
|
81
|
+
<script>AppShell.start();</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// The board view. Each app-*.js file registers a part with BoardView.part((B) => …); BoardView.create(root, site, opts)
|
|
2
|
+
// builds one board's B over its root element and its site root (such as '/hub-orchestrator', through ClaudeSite.site),
|
|
3
|
+
// and mount() runs the parts in load order, then renders the board from /api/board and per-item threads from
|
|
4
|
+
// /api/thread. Actions go out as events through B.site.send (each wakes Claude). Parts add listeners, intervals,
|
|
5
|
+
// timers, watches and unmount steps only through B.on, B.every, B.later, B.watch and B.atUnmount, so unmount() undoes
|
|
6
|
+
// them all and no board's state outlives its view. BoardView.markup({title}) is the root's inner
|
|
7
|
+
// HTML. BoardView.page(root, site, run) gives a board's other views (view-*.js) the same site, $ and recorders.
|
|
8
|
+
// opts, both from the app shell (shell.js):
|
|
9
|
+
// signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
|
|
10
|
+
// live {fresh, generatedAt}
|
|
11
|
+
// this board was left fresh and hasn't changed since, so a stored last visit whose board has
|
|
12
|
+
// that generatedAt paints live, with the header time `fresh`, rather than dimmed
|
|
13
|
+
// Load this after ui-*.js, attach.js and toasts.js, and before the app-*.js files.
|
|
14
|
+
window.BoardView = (function () {
|
|
15
|
+
const parts = [];
|
|
16
|
+
const part = (fn) => { parts.push(fn); };
|
|
17
|
+
|
|
18
|
+
// Every board shares the hub's one origin, so each keeps its keys under site:<route>:; app.html's preload reads
|
|
19
|
+
// openItems by the same key. Thread and question drafts are already per card and keep their own keys.
|
|
20
|
+
function storeFor(route) {
|
|
21
|
+
const keyOf = (k) => (/^(draft:(?!ask$)|qdraft:)/.test(k) ? k : `site:${route}:${k}`);
|
|
22
|
+
// The keys boards kept unprefixed move once, to the first board opened; a key whose new name is taken stays.
|
|
23
|
+
try {
|
|
24
|
+
const old = [];
|
|
25
|
+
for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
|
|
26
|
+
for (const k of old.filter((k) => /^(openItems|wave|lanes|seenThreads|dismissedAnswers|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
27
|
+
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
28
|
+
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
29
|
+
localStorage.removeItem(k);
|
|
30
|
+
}
|
|
31
|
+
} catch { /* no storage: nothing to move */ }
|
|
32
|
+
return {
|
|
33
|
+
get(k, d) { try { const v = localStorage.getItem(keyOf(k)); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
|
34
|
+
set(k, v) { try { localStorage.setItem(keyOf(k), JSON.stringify(v)); } catch {} },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What every view of one site shares: its ClaudeSite.site, a $ and $$ scoped to its root, and the recorders. Each
|
|
39
|
+
// recorder returns a function that undoes what it added, and stop() runs every one still pending, so a view adds
|
|
40
|
+
// listeners, intervals, timers, watches and unmount steps only through them. Before start() and after
|
|
41
|
+
// stop() they add nothing. B.sleep(ms) resolves after ms, and never once the view is gone, so a retry loop stops.
|
|
42
|
+
function base(root, siteRoot) {
|
|
43
|
+
const site = ClaudeSite.site(siteRoot);
|
|
44
|
+
const route = new URL(site.url('')).pathname.replace(/^\/+|\/+$/g, '');
|
|
45
|
+
// attach.js uploads each composer's files to the site of the [data-site] around it.
|
|
46
|
+
root.dataset.site = site.root;
|
|
47
|
+
let alive = false;
|
|
48
|
+
const undo = new Set();
|
|
49
|
+
const own = (off) => { undo.add(off); return () => { undo.delete(off); off(); }; };
|
|
50
|
+
const on = (target, type, fn, opts) => {
|
|
51
|
+
if (!alive || !target) return () => {};
|
|
52
|
+
target.addEventListener(type, fn, opts);
|
|
53
|
+
return own(() => target.removeEventListener(type, fn, opts));
|
|
54
|
+
};
|
|
55
|
+
const every = (ms, fn) => {
|
|
56
|
+
if (!alive) return () => {};
|
|
57
|
+
const t = setInterval(fn, ms);
|
|
58
|
+
return own(() => clearInterval(t));
|
|
59
|
+
};
|
|
60
|
+
const later = (ms, fn) => {
|
|
61
|
+
if (!alive) return () => {};
|
|
62
|
+
const off = () => clearTimeout(t);
|
|
63
|
+
const t = setTimeout(() => { undo.delete(off); fn(); }, ms);
|
|
64
|
+
return own(off);
|
|
65
|
+
};
|
|
66
|
+
const sleep = (ms) => new Promise((resolve) => { later(ms, resolve); });
|
|
67
|
+
const watch = (fn, opts) => {
|
|
68
|
+
if (!alive) return null;
|
|
69
|
+
const w = site.watch(fn, opts);
|
|
70
|
+
own(() => w.stop());
|
|
71
|
+
return w;
|
|
72
|
+
};
|
|
73
|
+
const atUnmount = (fn) => { if (alive) own(fn); };
|
|
74
|
+
const $ = (s, el = root) => el.querySelector(s);
|
|
75
|
+
const $$ = (s, el = root) => el.querySelectorAll(s);
|
|
76
|
+
let toasts = null;
|
|
77
|
+
const toast = (text) => {
|
|
78
|
+
if (!alive) return;
|
|
79
|
+
if (!toasts) {
|
|
80
|
+
toasts = BoardToasts.create(root);
|
|
81
|
+
own(() => { toasts.destroy(); toasts = null; });
|
|
82
|
+
}
|
|
83
|
+
toasts.toast(text);
|
|
84
|
+
};
|
|
85
|
+
const B = { root, site, route, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
|
|
86
|
+
return {
|
|
87
|
+
B,
|
|
88
|
+
start() { alive = true; },
|
|
89
|
+
stop() {
|
|
90
|
+
alive = false;
|
|
91
|
+
for (const off of [...undo]) off();
|
|
92
|
+
undo.clear();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function create(root, siteRoot, opts = {}) {
|
|
98
|
+
const { B, start: begin, stop } = base(root, siteRoot);
|
|
99
|
+
const store = storeFor(B.route);
|
|
100
|
+
// board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
|
|
101
|
+
// header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
|
|
102
|
+
// {thread, data, text}, data being what ask-edited sends besides the text.
|
|
103
|
+
const state = {
|
|
104
|
+
board: null,
|
|
105
|
+
fresh: null,
|
|
106
|
+
openItems: new Set(store.get('openItems', [])),
|
|
107
|
+
threadCache: new Map(),
|
|
108
|
+
withdrawnAsks: new Set(),
|
|
109
|
+
edits: new Map(),
|
|
110
|
+
githubPending: null,
|
|
111
|
+
waveFilter: store.get('wave', null),
|
|
112
|
+
};
|
|
113
|
+
const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
|
|
114
|
+
Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause });
|
|
115
|
+
|
|
116
|
+
function mount() {
|
|
117
|
+
if (B.alive()) return;
|
|
118
|
+
begin();
|
|
119
|
+
for (const p of parts) p(B);
|
|
120
|
+
start(B);
|
|
121
|
+
}
|
|
122
|
+
return { B, mount, unmount: stop };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A view other than the board: run(B) adds what it needs through B's recorders when the view mounts.
|
|
126
|
+
// → {B, mount, unmount}
|
|
127
|
+
function page(root, siteRoot, run) {
|
|
128
|
+
const { B, start: begin, stop } = base(root, siteRoot);
|
|
129
|
+
return {
|
|
130
|
+
B,
|
|
131
|
+
mount() { if (B.alive()) return; begin(); run(B); },
|
|
132
|
+
unmount: stop,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The board itself, after every part: rendering, the #item= hash, loading and the change signal.
|
|
137
|
+
function start(B) {
|
|
138
|
+
const { $, $$, store, state, steady, markSeen } = B;
|
|
139
|
+
const { setHTML, preloadKeys } = BoardUI;
|
|
140
|
+
|
|
141
|
+
// Renders open threads from state.threadCache; loadBoard and opening a card load them.
|
|
142
|
+
function render() {
|
|
143
|
+
const d = state.board;
|
|
144
|
+
const title = $(':scope > .top h1');
|
|
145
|
+
if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
|
|
146
|
+
if (d.title && document.title !== d.title) document.title = d.title;
|
|
147
|
+
steady(() => {
|
|
148
|
+
B.renderHeader(d);
|
|
149
|
+
B.renderWaves(d);
|
|
150
|
+
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
|
|
151
|
+
const live = d.items.filter((i) => !state.withdrawnAsks.has(i.id));
|
|
152
|
+
const shown = state.waveFilter ? live.filter((i) => i.wave === state.waveFilter) : live;
|
|
153
|
+
for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
|
|
154
|
+
for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
|
|
155
|
+
// An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
|
|
156
|
+
setHTML($('#lanes'), B.lanesHtml(d, shown), { keep: '[data-thread]' });
|
|
157
|
+
for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
158
|
+
B.renderNeeds(d);
|
|
159
|
+
B.renderBanner(d);
|
|
160
|
+
});
|
|
161
|
+
B.flashChanged();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
|
|
165
|
+
// (docs/adr/0036-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
166
|
+
let focusKey = null;
|
|
167
|
+
let replyKey = null;
|
|
168
|
+
let hashRead = false;
|
|
169
|
+
function readHash() {
|
|
170
|
+
const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
|
|
171
|
+
if (!m) return;
|
|
172
|
+
focusKey = decodeURIComponent(m[1]);
|
|
173
|
+
focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
|
|
174
|
+
replyKey = m[2] ? focusKey : null;
|
|
175
|
+
state.openItems.add(focusKey);
|
|
176
|
+
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
177
|
+
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); }
|
|
178
|
+
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
179
|
+
}
|
|
180
|
+
B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
|
|
181
|
+
// A card link whose hash is already the page's fires no hashchange, so its tap opens the card here.
|
|
182
|
+
B.on(B.root, 'click', (ev) => {
|
|
183
|
+
const a = ev.target.closest?.('a[href^="#item="]');
|
|
184
|
+
if (!a || ev.defaultPrevented || a.getAttribute('href') !== location.hash || !state.board) return;
|
|
185
|
+
ev.preventDefault();
|
|
186
|
+
readHash(); render(); scrollToFocus();
|
|
187
|
+
});
|
|
188
|
+
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
189
|
+
function scrollToFocus(final = true) {
|
|
190
|
+
if (!focusKey) return;
|
|
191
|
+
const el = $(`details.item[data-key="${CSS.escape(focusKey)}"]`);
|
|
192
|
+
// The desktop header, or below 900px the status bar, would cover the card's title row.
|
|
193
|
+
if (el) {
|
|
194
|
+
el.style.scrollMarginTop = matchMedia('(min-width: 900px)').matches
|
|
195
|
+
? `${($(':scope > .top')?.offsetHeight || 0) + 8}px` : 'calc(env(safe-area-inset-top) + 8px)';
|
|
196
|
+
el.scrollIntoView({ block: 'start' });
|
|
197
|
+
}
|
|
198
|
+
if (el && replyKey === focusKey) focusReply(replyKey);
|
|
199
|
+
if (el || final) focusKey = replyKey = null;
|
|
200
|
+
}
|
|
201
|
+
// The thread loads after the card opens, so its reply box is waited for, briefly. iOS shows no keyboard for a
|
|
202
|
+
// focus that no tap started, so the box is also brought into view: one tap on it then types.
|
|
203
|
+
function focusReply(key, tries = 20) {
|
|
204
|
+
const ta = $(`form[data-reply="${CSS.escape(key)}"] textarea`);
|
|
205
|
+
if (!ta) { if (tries > 0) B.later(150, () => focusReply(key, tries - 1)); return; }
|
|
206
|
+
ta.scrollIntoView({ block: 'center' });
|
|
207
|
+
ta.focus({ preventScroll: true });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The last visit's board paints dimmed while the first load runs; it doesn't count as landed, so refresh keeps
|
|
211
|
+
// spinning, and the header's time is the snapshot's until a load lands (docs/adr/0028-the-board-opens-on-the-last-visit.md).
|
|
212
|
+
// → true when it painted live (B.live), so the controls turn on at once.
|
|
213
|
+
function paintSnapshot() {
|
|
214
|
+
const snap = B.readSnapshot();
|
|
215
|
+
if (!snap) return false;
|
|
216
|
+
const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
|
|
217
|
+
state.board = snap.board;
|
|
218
|
+
for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
|
|
219
|
+
state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
|
|
220
|
+
B.setView(live ? 'fresh' : 'snapshot');
|
|
221
|
+
readHash();
|
|
222
|
+
hashRead = true;
|
|
223
|
+
render();
|
|
224
|
+
scrollToFocus(false);
|
|
225
|
+
if (!live) B.holdForFlash();
|
|
226
|
+
return live;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
|
|
230
|
+
// its own board. Each load fetches the open cards' threads beside the
|
|
231
|
+
// board. via is ui-header.js nextFresh's: a refresh or a change signal fetches every open thread anew rather than
|
|
232
|
+
// share a load that started before the change.
|
|
233
|
+
let boot = globalThis.BoardBoot || null;
|
|
234
|
+
globalThis.BoardBoot = null;
|
|
235
|
+
if (boot?.route && boot.route !== B.route) boot = null;
|
|
236
|
+
let preloaded = false;
|
|
237
|
+
async function loadBoard({ via = 'poll' } = {}) {
|
|
238
|
+
const started = boot;
|
|
239
|
+
boot = null;
|
|
240
|
+
const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
|
|
241
|
+
let landed = false;
|
|
242
|
+
try {
|
|
243
|
+
const board = await (started?.board || fetch(B.site.url('api/board'), { cache: 'no-store' })
|
|
244
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
|
|
245
|
+
const first = state.view !== 'fresh';
|
|
246
|
+
state.board = board;
|
|
247
|
+
B.setView('fresh');
|
|
248
|
+
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
249
|
+
B.linkUp();
|
|
250
|
+
render();
|
|
251
|
+
scrollToFocus();
|
|
252
|
+
B.site.syncBadge();
|
|
253
|
+
B.saveSnapshotSoon();
|
|
254
|
+
landed = true;
|
|
255
|
+
if (!preloaded) {
|
|
256
|
+
preloaded = true;
|
|
257
|
+
for (const key of preloadKeys(board.items, state.openItems)) B.loadThread(key);
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
B.linkDown();
|
|
261
|
+
}
|
|
262
|
+
await Promise.all(threads);
|
|
263
|
+
if (landed) B.landed(via);
|
|
264
|
+
if (state.view === 'fresh') B.endFlash();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// The server signals each change to what the board reads, through the shell, and the board reloads at once; the
|
|
268
|
+
// 30 s poll stays as the fallback and the offline check (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
|
|
269
|
+
let reloading = null;
|
|
270
|
+
let reloadAgain = false;
|
|
271
|
+
let signalMissed = false;
|
|
272
|
+
function signalled() {
|
|
273
|
+
if (document.hidden) { signalMissed = true; return; }
|
|
274
|
+
if (reloading) { reloadAgain = true; return; }
|
|
275
|
+
reloading = loadBoard({ via: 'signal' }).finally(() => {
|
|
276
|
+
reloading = null;
|
|
277
|
+
if (reloadAgain) { reloadAgain = false; signalled(); }
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
Object.assign(B, { render, loadBoard });
|
|
282
|
+
const live = paintSnapshot();
|
|
283
|
+
B.headerWired();
|
|
284
|
+
if (live) B.linkUp();
|
|
285
|
+
const sent = BoardUI.sentNotice.take(B.route);
|
|
286
|
+
if (sent) B.toast(sent);
|
|
287
|
+
B.on(document, 'visibilitychange', () => {
|
|
288
|
+
if (document.hidden) return;
|
|
289
|
+
if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
|
|
290
|
+
});
|
|
291
|
+
loadBoard();
|
|
292
|
+
B.signal?.(signalled);
|
|
293
|
+
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function markup({ title = '' } = {}) {
|
|
297
|
+
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png' })}
|
|
298
|
+
<main>
|
|
299
|
+
<section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
300
|
+
<section id="answers" class="answer-banner" hidden></section>
|
|
301
|
+
<section id="waiting" class="waiting" hidden></section>
|
|
302
|
+
<div id="waves" class="waves"></div>
|
|
303
|
+
<div id="tryMain"></div>
|
|
304
|
+
<div id="lanes"></div>
|
|
305
|
+
</main>
|
|
306
|
+
<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
307
|
+
<dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
|
|
308
|
+
<form id="askForm" data-composer="ask">
|
|
309
|
+
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
310
|
+
<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>
|
|
311
|
+
<div class="pending-files" data-files></div>
|
|
312
|
+
<div class="row">
|
|
313
|
+
<span class="muted small" id="askStatus"></span>
|
|
314
|
+
<button type="submit" class="primary">Send</button>
|
|
315
|
+
</div>
|
|
316
|
+
</form>
|
|
317
|
+
</dialog>
|
|
318
|
+
<dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
|
|
319
|
+
<div class="sheet-body">
|
|
320
|
+
<div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
|
|
321
|
+
<div id="linksList"></div>
|
|
322
|
+
</div>
|
|
323
|
+
</dialog>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { part, create, page, markup };
|
|
327
|
+
})();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Files attached to a composer: any element with data-composer="<id>" holding BoardUI.attachButton()
|
|
2
|
+
// and a [data-files] list. Picking files uploads them at once to the api/upload of the site named by the nearest
|
|
3
|
+
// [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
|
|
4
|
+
// files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
|
|
5
|
+
// again, and a board the app shell switches away from keeps its own. site defaults to the page's own below.
|
|
6
|
+
// BoardAttach.files(id, site) the uploaded files, ready for an event's data.files
|
|
7
|
+
// BoardAttach.busy(id, site) true while any upload is running
|
|
8
|
+
// BoardAttach.clear(id, site) after sending
|
|
9
|
+
// BoardAttach.onChange(fn) fn(id) after each change, such as to disable Send while busy; each composer
|
|
10
|
+
// painted also gets a bubbling attach-change event with detail {id}
|
|
11
|
+
window.BoardAttach = (function () {
|
|
12
|
+
const LIMIT = 20 * 1024 * 1024;
|
|
13
|
+
const state = new Map();
|
|
14
|
+
const listeners = [];
|
|
15
|
+
let seq = 0;
|
|
16
|
+
const own = () => ClaudeSite.root;
|
|
17
|
+
const siteOf = (el) => el.closest('[data-site]')?.dataset.site || own();
|
|
18
|
+
const keyOf = (site, id) => `${site}|${id}`;
|
|
19
|
+
const list = (site, id) => state.get(keyOf(site, id)) || [];
|
|
20
|
+
const composers = (site, id) => [...document.querySelectorAll(`[data-composer="${CSS.escape(id)}"]`)].filter((c) => siteOf(c) === site);
|
|
21
|
+
|
|
22
|
+
function paint(id, site = own()) {
|
|
23
|
+
for (const c of composers(site, id)) {
|
|
24
|
+
const el = c.querySelector('[data-files]');
|
|
25
|
+
if (el) el.innerHTML = BoardUI.pendingFiles(list(site, id));
|
|
26
|
+
c.dispatchEvent(new CustomEvent('attach-change', { bubbles: true, detail: { id } }));
|
|
27
|
+
}
|
|
28
|
+
for (const fn of listeners) fn(id);
|
|
29
|
+
}
|
|
30
|
+
const paintAll = () => { for (const c of document.querySelectorAll('[data-composer]')) paint(c.dataset.composer, siteOf(c)); };
|
|
31
|
+
|
|
32
|
+
function upload(site, id, file) {
|
|
33
|
+
const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0 };
|
|
34
|
+
if (/^image\//.test(p.type)) p.preview = URL.createObjectURL(file);
|
|
35
|
+
state.set(keyOf(site, id), [...list(site, id), p]);
|
|
36
|
+
if (file.size > LIMIT) { p.status = 'error'; p.error = 'Over 20 MB'; paint(id, site); return; }
|
|
37
|
+
paint(id, site);
|
|
38
|
+
const xhr = new XMLHttpRequest();
|
|
39
|
+
p.xhr = xhr;
|
|
40
|
+
xhr.open('POST', `${site}/api/upload`);
|
|
41
|
+
xhr.setRequestHeader('x-filename', encodeURIComponent(p.name));
|
|
42
|
+
xhr.setRequestHeader('content-type', p.type);
|
|
43
|
+
xhr.upload.onprogress = (e) => { if (e.lengthComputable) { p.progress = e.loaded / e.total; paint(id, site); } };
|
|
44
|
+
xhr.onload = () => {
|
|
45
|
+
let body = null;
|
|
46
|
+
try { body = JSON.parse(xhr.responseText); } catch {}
|
|
47
|
+
if (xhr.status === 200 && body) { p.status = 'done'; p.file = body; }
|
|
48
|
+
else { p.status = 'error'; p.error = xhr.status === 413 ? 'Over 20 MB' : (body && body.error) || 'Upload failed'; }
|
|
49
|
+
paint(id, site);
|
|
50
|
+
};
|
|
51
|
+
xhr.onerror = () => { p.status = 'error'; p.error = 'Upload failed'; paint(id, site); };
|
|
52
|
+
xhr.send(file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function remove(site, id, fileId) {
|
|
56
|
+
const p = list(site, id).find((x) => x.id === fileId);
|
|
57
|
+
if (!p) return;
|
|
58
|
+
if (p.status === 'uploading') p.xhr.abort();
|
|
59
|
+
if (p.preview) URL.revokeObjectURL(p.preview);
|
|
60
|
+
state.set(keyOf(site, id), list(site, id).filter((x) => x !== p));
|
|
61
|
+
paint(id, site);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
document.addEventListener('change', (ev) => {
|
|
65
|
+
const input = ev.target.closest && ev.target.closest('input[data-attach]');
|
|
66
|
+
const c = input && input.closest('[data-composer]');
|
|
67
|
+
if (!c) return;
|
|
68
|
+
for (const f of input.files) upload(siteOf(c), c.dataset.composer, f);
|
|
69
|
+
input.value = '';
|
|
70
|
+
});
|
|
71
|
+
document.addEventListener('click', (ev) => {
|
|
72
|
+
const b = ev.target.closest && ev.target.closest('[data-unattach]');
|
|
73
|
+
const c = b && b.closest('[data-composer]');
|
|
74
|
+
if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
files: (id, site = own()) => list(site, id).filter((p) => p.status === 'done').map((p) => p.file),
|
|
79
|
+
busy: (id, site = own()) => list(site, id).some((p) => p.status === 'uploading'),
|
|
80
|
+
clear(id, site = own()) { for (const p of list(site, id)) if (p.preview) URL.revokeObjectURL(p.preview); state.delete(keyOf(site, id)); paint(id, site); },
|
|
81
|
+
onChange(fn) { listeners.push(fn); },
|
|
82
|
+
paint, paintAll,
|
|
83
|
+
};
|
|
84
|
+
})();
|