@bakapiano/ccsm 0.22.6 → 0.22.8
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/CLAUDE.md +521 -540
- package/README.md +186 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +36 -139
- package/lib/codexSeed.js +126 -183
- package/lib/config.js +277 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/persistedSessions.js +179 -139
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/winPath.js +1 -1
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +154 -154
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +546 -546
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2347 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +349 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +28 -0
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +730 -713
- package/public/js/pages/LaunchPage.js +403 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +54 -54
- package/public/js/state.js +335 -335
- package/public/js/util.js +1 -1
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1748 -1817
- package/lib/localCliSessions.js +0 -519
- package/public/js/components/AdoptModal.js +0 -261
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
// Full-screen blocker shown on the remote browser while waiting for
|
|
2
|
-
// the host to approve this device. Triggered by api.js setting the
|
|
3
|
-
// pendingDevice signal in response to a 403 {pending:true}.
|
|
4
|
-
//
|
|
5
|
-
// While visible, polls /api/devices/me every 3s. When the server
|
|
6
|
-
// flips status to 'approved', the next polled api() call clears
|
|
7
|
-
// pendingDevice (api.js does that on any 2xx response), the overlay
|
|
8
|
-
// unmounts, and the rest of the app keeps loading.
|
|
9
|
-
//
|
|
10
|
-
// Reuses .offline-overlay / .offline-card classes so styling matches
|
|
11
|
-
// the existing HealthOverlay's blocking modal aesthetic.
|
|
12
|
-
|
|
13
|
-
import { html } from '../html.js';
|
|
14
|
-
import { useEffect } from 'preact/hooks';
|
|
15
|
-
import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
|
|
16
|
-
import { getDeviceCode } from '../backend.js';
|
|
17
|
-
import { BrandMark } from '../icons.js';
|
|
18
|
-
|
|
19
|
-
const POLL_MS = 3000;
|
|
20
|
-
|
|
21
|
-
export function PendingApprovalOverlay() {
|
|
22
|
-
const p = pendingDevice.value;
|
|
23
|
-
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
if (!p) return;
|
|
26
|
-
let stopped = false;
|
|
27
|
-
const tick = async () => {
|
|
28
|
-
if (stopped) return;
|
|
29
|
-
try {
|
|
30
|
-
// /api/devices/me is gate-exempt: returns 200 with the current
|
|
31
|
-
// device record regardless of approval state. We inspect the
|
|
32
|
-
// body ourselves to decide whether to dismiss the overlay.
|
|
33
|
-
const d = await api('GET', '/api/devices/me');
|
|
34
|
-
if (d && d.status === 'approved') {
|
|
35
|
-
pendingDevice.value = null;
|
|
36
|
-
// First load failed because we weren't approved yet — main.js'
|
|
37
|
-
// boot tried /api/config and got 401, no auto-retry. Now that
|
|
38
|
-
// we're through the gate, kick a one-shot load so config +
|
|
39
|
-
// sessions/folders/workspaces hydrate without waiting for the
|
|
40
|
-
// 5s tick (and config without that ever happening, since the
|
|
41
|
-
// periodic loop doesn't include loadConfig).
|
|
42
|
-
loadConfig().catch(() => {});
|
|
43
|
-
refreshAll().catch(() => {});
|
|
44
|
-
} else if (d) {
|
|
45
|
-
// Preserve any fields already set (firstSeen survives the
|
|
46
|
-
// overlay's repeated polls; staleToken from a previous tick
|
|
47
|
-
// gets cleared since we just got a 200).
|
|
48
|
-
pendingDevice.value = {
|
|
49
|
-
...(pendingDevice.value || {}),
|
|
50
|
-
pending: d.status === 'pending',
|
|
51
|
-
rejected: d.status === 'rejected',
|
|
52
|
-
deviceId: d.id,
|
|
53
|
-
firstSeen: d.firstSeen,
|
|
54
|
-
staleToken: false,
|
|
55
|
-
at: Date.now(),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
} catch (e) {
|
|
59
|
-
// /me 401s in exactly one situation: our device record was
|
|
60
|
-
// pruned (24h pending TTL) AND the host's current token no
|
|
61
|
-
// longer matches the one in our localStorage. We can't
|
|
62
|
-
// self-recover from this — a fresh share URL is needed. Surface
|
|
63
|
-
// it so the user gets honest feedback instead of an indefinite
|
|
64
|
-
// "Waiting for approval" loop.
|
|
65
|
-
const msg = String(e?.message || '');
|
|
66
|
-
if (/401/.test(msg) || /token/i.test(msg)) {
|
|
67
|
-
pendingDevice.value = {
|
|
68
|
-
...(pendingDevice.value || {}),
|
|
69
|
-
staleToken: true,
|
|
70
|
-
pending: false,
|
|
71
|
-
rejected: false,
|
|
72
|
-
at: Date.now(),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
/* anything else: network blip — try again next tick */
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
const id = setInterval(tick, POLL_MS);
|
|
79
|
-
tick();
|
|
80
|
-
return () => { stopped = true; clearInterval(id); };
|
|
81
|
-
}, [!!p]);
|
|
82
|
-
|
|
83
|
-
if (!p) return null;
|
|
84
|
-
|
|
85
|
-
const rejected = !!p.rejected;
|
|
86
|
-
const staleToken = !!p.staleToken;
|
|
87
|
-
const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
|
|
88
|
-
|
|
89
|
-
return html`
|
|
90
|
-
<div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
|
91
|
-
<div class="offline-card">
|
|
92
|
-
<div class="offline-brand"><${BrandMark} /></div>
|
|
93
|
-
${staleToken ? html`
|
|
94
|
-
<h1 class="offline-title">Link expired</h1>
|
|
95
|
-
<p class="offline-copy">
|
|
96
|
-
This share link no longer works — the host either rotated the
|
|
97
|
-
registration token or this device was pruned after sitting
|
|
98
|
-
unapproved for 24 hours.
|
|
99
|
-
</p>
|
|
100
|
-
<p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
|
|
101
|
-
Ask the operator to send a fresh share URL from the Remote page.
|
|
102
|
-
</p>
|
|
103
|
-
` : rejected ? html`
|
|
104
|
-
<h1 class="offline-title">Access declined</h1>
|
|
105
|
-
<p class="offline-copy">
|
|
106
|
-
The host machine rejected this device. If you think this was a
|
|
107
|
-
mistake, ask the operator to re-approve from the Remote page.
|
|
108
|
-
</p>
|
|
109
|
-
` : html`
|
|
110
|
-
<h1 class="offline-title">Waiting for host approval</h1>
|
|
111
|
-
<p class="offline-copy">
|
|
112
|
-
The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
|
|
113
|
-
Approve this device from the Remote page over there to continue.
|
|
114
|
-
</p>
|
|
115
|
-
${getDeviceCode() ? html`
|
|
116
|
-
<div class="offline-code-block">
|
|
117
|
-
<div class="offline-code-label">Your code</div>
|
|
118
|
-
<div class="offline-code">${getDeviceCode()}</div>
|
|
119
|
-
<div class="offline-code-hint">Match this against what the host sees before they Approve.</div>
|
|
120
|
-
</div>
|
|
121
|
-
` : null}
|
|
122
|
-
<p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
|
|
123
|
-
We'll auto-unlock the moment the host clicks Approve.
|
|
124
|
-
</p>
|
|
125
|
-
`}
|
|
126
|
-
</div>
|
|
127
|
-
</div>`;
|
|
128
|
-
}
|
|
1
|
+
// Full-screen blocker shown on the remote browser while waiting for
|
|
2
|
+
// the host to approve this device. Triggered by api.js setting the
|
|
3
|
+
// pendingDevice signal in response to a 403 {pending:true}.
|
|
4
|
+
//
|
|
5
|
+
// While visible, polls /api/devices/me every 3s. When the server
|
|
6
|
+
// flips status to 'approved', the next polled api() call clears
|
|
7
|
+
// pendingDevice (api.js does that on any 2xx response), the overlay
|
|
8
|
+
// unmounts, and the rest of the app keeps loading.
|
|
9
|
+
//
|
|
10
|
+
// Reuses .offline-overlay / .offline-card classes so styling matches
|
|
11
|
+
// the existing HealthOverlay's blocking modal aesthetic.
|
|
12
|
+
|
|
13
|
+
import { html } from '../html.js';
|
|
14
|
+
import { useEffect } from 'preact/hooks';
|
|
15
|
+
import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
|
|
16
|
+
import { getDeviceCode } from '../backend.js';
|
|
17
|
+
import { BrandMark } from '../icons.js';
|
|
18
|
+
|
|
19
|
+
const POLL_MS = 3000;
|
|
20
|
+
|
|
21
|
+
export function PendingApprovalOverlay() {
|
|
22
|
+
const p = pendingDevice.value;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!p) return;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
const tick = async () => {
|
|
28
|
+
if (stopped) return;
|
|
29
|
+
try {
|
|
30
|
+
// /api/devices/me is gate-exempt: returns 200 with the current
|
|
31
|
+
// device record regardless of approval state. We inspect the
|
|
32
|
+
// body ourselves to decide whether to dismiss the overlay.
|
|
33
|
+
const d = await api('GET', '/api/devices/me');
|
|
34
|
+
if (d && d.status === 'approved') {
|
|
35
|
+
pendingDevice.value = null;
|
|
36
|
+
// First load failed because we weren't approved yet — main.js'
|
|
37
|
+
// boot tried /api/config and got 401, no auto-retry. Now that
|
|
38
|
+
// we're through the gate, kick a one-shot load so config +
|
|
39
|
+
// sessions/folders/workspaces hydrate without waiting for the
|
|
40
|
+
// 5s tick (and config without that ever happening, since the
|
|
41
|
+
// periodic loop doesn't include loadConfig).
|
|
42
|
+
loadConfig().catch(() => {});
|
|
43
|
+
refreshAll().catch(() => {});
|
|
44
|
+
} else if (d) {
|
|
45
|
+
// Preserve any fields already set (firstSeen survives the
|
|
46
|
+
// overlay's repeated polls; staleToken from a previous tick
|
|
47
|
+
// gets cleared since we just got a 200).
|
|
48
|
+
pendingDevice.value = {
|
|
49
|
+
...(pendingDevice.value || {}),
|
|
50
|
+
pending: d.status === 'pending',
|
|
51
|
+
rejected: d.status === 'rejected',
|
|
52
|
+
deviceId: d.id,
|
|
53
|
+
firstSeen: d.firstSeen,
|
|
54
|
+
staleToken: false,
|
|
55
|
+
at: Date.now(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// /me 401s in exactly one situation: our device record was
|
|
60
|
+
// pruned (24h pending TTL) AND the host's current token no
|
|
61
|
+
// longer matches the one in our localStorage. We can't
|
|
62
|
+
// self-recover from this — a fresh share URL is needed. Surface
|
|
63
|
+
// it so the user gets honest feedback instead of an indefinite
|
|
64
|
+
// "Waiting for approval" loop.
|
|
65
|
+
const msg = String(e?.message || '');
|
|
66
|
+
if (/401/.test(msg) || /token/i.test(msg)) {
|
|
67
|
+
pendingDevice.value = {
|
|
68
|
+
...(pendingDevice.value || {}),
|
|
69
|
+
staleToken: true,
|
|
70
|
+
pending: false,
|
|
71
|
+
rejected: false,
|
|
72
|
+
at: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/* anything else: network blip — try again next tick */
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const id = setInterval(tick, POLL_MS);
|
|
79
|
+
tick();
|
|
80
|
+
return () => { stopped = true; clearInterval(id); };
|
|
81
|
+
}, [!!p]);
|
|
82
|
+
|
|
83
|
+
if (!p) return null;
|
|
84
|
+
|
|
85
|
+
const rejected = !!p.rejected;
|
|
86
|
+
const staleToken = !!p.staleToken;
|
|
87
|
+
const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
|
|
88
|
+
|
|
89
|
+
return html`
|
|
90
|
+
<div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
|
91
|
+
<div class="offline-card">
|
|
92
|
+
<div class="offline-brand"><${BrandMark} /></div>
|
|
93
|
+
${staleToken ? html`
|
|
94
|
+
<h1 class="offline-title">Link expired</h1>
|
|
95
|
+
<p class="offline-copy">
|
|
96
|
+
This share link no longer works — the host either rotated the
|
|
97
|
+
registration token or this device was pruned after sitting
|
|
98
|
+
unapproved for 24 hours.
|
|
99
|
+
</p>
|
|
100
|
+
<p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
|
|
101
|
+
Ask the operator to send a fresh share URL from the Remote page.
|
|
102
|
+
</p>
|
|
103
|
+
` : rejected ? html`
|
|
104
|
+
<h1 class="offline-title">Access declined</h1>
|
|
105
|
+
<p class="offline-copy">
|
|
106
|
+
The host machine rejected this device. If you think this was a
|
|
107
|
+
mistake, ask the operator to re-approve from the Remote page.
|
|
108
|
+
</p>
|
|
109
|
+
` : html`
|
|
110
|
+
<h1 class="offline-title">Waiting for host approval</h1>
|
|
111
|
+
<p class="offline-copy">
|
|
112
|
+
The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
|
|
113
|
+
Approve this device from the Remote page over there to continue.
|
|
114
|
+
</p>
|
|
115
|
+
${getDeviceCode() ? html`
|
|
116
|
+
<div class="offline-code-block">
|
|
117
|
+
<div class="offline-code-label">Your code</div>
|
|
118
|
+
<div class="offline-code">${getDeviceCode()}</div>
|
|
119
|
+
<div class="offline-code-hint">Match this against what the host sees before they Approve.</div>
|
|
120
|
+
</div>
|
|
121
|
+
` : null}
|
|
122
|
+
<p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
|
|
123
|
+
We'll auto-unlock the moment the host clicks Approve.
|
|
124
|
+
</p>
|
|
125
|
+
`}
|
|
126
|
+
</div>
|
|
127
|
+
</div>`;
|
|
128
|
+
}
|
|
@@ -1,179 +1,179 @@
|
|
|
1
|
-
// Unified picker used inside a Popover. Renders:
|
|
2
|
-
// - optional search box (filters items by `label` + `meta`)
|
|
3
|
-
// - scrollable item list (single or multi select)
|
|
4
|
-
// - footer "+ New <thing>" that expands an inline form
|
|
5
|
-
//
|
|
6
|
-
// items: [{ id, label, meta?, disabled? }]
|
|
7
|
-
// onSelect(id) — single select. Closes popover.
|
|
8
|
-
// onToggle(id, on) — multi select. Doesn't close.
|
|
9
|
-
// selectedIds: Set<string> — for multi select highlight
|
|
10
|
-
// selectedId: string — for single select highlight
|
|
11
|
-
// onCreate(values) — async; returns the newly-created id (selected immediately
|
|
12
|
-
// in single mode; added to selection in multi mode)
|
|
13
|
-
// createFields: [{ key, label, type?, placeholder?, required? }]
|
|
14
|
-
|
|
15
|
-
import { html } from '../html.js';
|
|
16
|
-
import { useState, useRef, useEffect } from 'preact/hooks';
|
|
17
|
-
import { IconSearch, IconPlus, IconClose } from '../icons.js';
|
|
18
|
-
|
|
19
|
-
export function PickerPanel({
|
|
20
|
-
title,
|
|
21
|
-
items,
|
|
22
|
-
selectedId,
|
|
23
|
-
selectedIds,
|
|
24
|
-
multi = false,
|
|
25
|
-
showSearch = true,
|
|
26
|
-
emptyHint = 'Nothing yet.',
|
|
27
|
-
createLabel = '+ New',
|
|
28
|
-
createFields = [],
|
|
29
|
-
onSelect,
|
|
30
|
-
onToggle,
|
|
31
|
-
onCreate,
|
|
32
|
-
onClose,
|
|
33
|
-
dnd,
|
|
34
|
-
}) {
|
|
35
|
-
const [q, setQ] = useState('');
|
|
36
|
-
const [creating, setCreating] = useState(false);
|
|
37
|
-
const [draft, setDraft] = useState(() => initialDraft(createFields));
|
|
38
|
-
const [saving, setSaving] = useState(false);
|
|
39
|
-
const searchRef = useRef(null);
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
if (showSearch) searchRef.current?.focus();
|
|
43
|
-
}, [showSearch]);
|
|
44
|
-
|
|
45
|
-
const filtered = items.filter((it) => {
|
|
46
|
-
if (!q.trim()) return true;
|
|
47
|
-
const hay = (it.label + ' ' + (it.meta || '')).toLowerCase();
|
|
48
|
-
return hay.includes(q.trim().toLowerCase());
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const submitCreate = async (ev) => {
|
|
52
|
-
ev?.preventDefault?.();
|
|
53
|
-
for (const f of createFields) {
|
|
54
|
-
if (f.required && !String(draft[f.key] || '').trim()) return;
|
|
55
|
-
}
|
|
56
|
-
setSaving(true);
|
|
57
|
-
try {
|
|
58
|
-
const id = await onCreate?.(draft);
|
|
59
|
-
setDraft(initialDraft(createFields));
|
|
60
|
-
setCreating(false);
|
|
61
|
-
if (id != null) {
|
|
62
|
-
if (multi) onToggle?.(id, true);
|
|
63
|
-
else { onSelect?.(id); onClose?.(); }
|
|
64
|
-
}
|
|
65
|
-
} catch (e) {
|
|
66
|
-
// Caller is expected to toast; we just stay open with the form
|
|
67
|
-
console.warn(e);
|
|
68
|
-
} finally {
|
|
69
|
-
setSaving(false);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
return html`
|
|
74
|
-
<div class="picker">
|
|
75
|
-
${title ? html`<div class="picker-title">${title}</div>` : null}
|
|
76
|
-
|
|
77
|
-
${showSearch ? html`
|
|
78
|
-
<div class="picker-search">
|
|
79
|
-
<span class="picker-search-icon"><${IconSearch} /></span>
|
|
80
|
-
<input ref=${searchRef} class="picker-search-input"
|
|
81
|
-
placeholder="Search…" value=${q}
|
|
82
|
-
onInput=${(e) => setQ(e.target.value)} />
|
|
83
|
-
${q ? html`<button class="picker-search-clear" onClick=${() => setQ('')}>
|
|
84
|
-
<${IconClose} />
|
|
85
|
-
</button>` : null}
|
|
86
|
-
</div>` : null}
|
|
87
|
-
|
|
88
|
-
<div class="picker-list">
|
|
89
|
-
${filtered.length === 0 ? html`
|
|
90
|
-
<div class="picker-empty">${q ? 'No matches.' : emptyHint}</div>
|
|
91
|
-
` : filtered.map((it) => {
|
|
92
|
-
const isSel = multi ? selectedIds?.has(it.id) : selectedId === it.id;
|
|
93
|
-
const enableDnd = dnd && !it.disabled && !it.undraggable && !q.trim();
|
|
94
|
-
const rowProps = enableDnd ? dnd.rowProps(it.id) : {};
|
|
95
|
-
const handleProps = enableDnd ? dnd.handleProps(it.id) : {};
|
|
96
|
-
return html`
|
|
97
|
-
<div key=${it.id} class=${`picker-item-wrap${enableDnd ? ' is-draggable' : ''}`} ...${rowProps} ...${handleProps}>
|
|
98
|
-
${enableDnd ? html`<span class="picker-item-grip" aria-hidden="true">⋮⋮</span>` : null}
|
|
99
|
-
<button type="button"
|
|
100
|
-
class=${`picker-item${isSel ? ' is-selected' : ''}`}
|
|
101
|
-
disabled=${it.disabled}
|
|
102
|
-
onClick=${() => {
|
|
103
|
-
if (multi) onToggle?.(it.id, !isSel);
|
|
104
|
-
else { onSelect?.(it.id); onClose?.(); }
|
|
105
|
-
}}>
|
|
106
|
-
${it.icon ? html`<span class="picker-item-icon">${it.icon}</span>` : null}
|
|
107
|
-
<span class="picker-item-label">${it.label}</span>
|
|
108
|
-
${it.meta ? html`<span class="picker-item-meta">${it.meta}</span>` : null}
|
|
109
|
-
${isSel ? html`<span class="picker-item-check">✓</span>` : null}
|
|
110
|
-
</button>
|
|
111
|
-
</div>`;
|
|
112
|
-
})}
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
${onCreate ? html`
|
|
116
|
-
<div class="picker-create">
|
|
117
|
-
${!creating ? html`
|
|
118
|
-
<button class="picker-create-toggle" type="button"
|
|
119
|
-
onClick=${() => setCreating(true)}>
|
|
120
|
-
<${IconPlus} />
|
|
121
|
-
<span>${createLabel}</span>
|
|
122
|
-
</button>
|
|
123
|
-
` : html`
|
|
124
|
-
<form class="picker-create-form" onSubmit=${submitCreate}>
|
|
125
|
-
${createFields.map((f) => html`
|
|
126
|
-
<label class="picker-field" key=${f.key}>
|
|
127
|
-
<span class="picker-field-label">${f.label}</span>
|
|
128
|
-
${f.type === 'select' ? html`
|
|
129
|
-
<select class="input" value=${draft[f.key] || ''}
|
|
130
|
-
onChange=${(e) => {
|
|
131
|
-
const next = { ...draft, [f.key]: e.target.value };
|
|
132
|
-
const side = f.onChange?.(e.target.value, next);
|
|
133
|
-
setDraft(side ? { ...next, ...side } : next);
|
|
134
|
-
}}>
|
|
135
|
-
${(f.options || []).map((opt) => html`
|
|
136
|
-
<option value=${opt.value}>${opt.label}</option>`)}
|
|
137
|
-
</select>
|
|
138
|
-
` : f.type === 'iconRadio' ? html`
|
|
139
|
-
<div class="icon-radio">
|
|
140
|
-
${(f.options || []).map((opt) => html`
|
|
141
|
-
<button type="button" key=${opt.value}
|
|
142
|
-
class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
|
|
143
|
-
onClick=${() => {
|
|
144
|
-
const next = { ...draft, [f.key]: opt.value };
|
|
145
|
-
const side = f.onChange?.(opt.value, next);
|
|
146
|
-
setDraft(side ? { ...next, ...side } : next);
|
|
147
|
-
}}>
|
|
148
|
-
${opt.icon ? html`<span class="icon-radio-icon">${opt.icon}</span>` : null}
|
|
149
|
-
<span>${opt.label}</span>
|
|
150
|
-
</button>`)}
|
|
151
|
-
</div>
|
|
152
|
-
` : html`
|
|
153
|
-
<input type=${f.type || 'text'}
|
|
154
|
-
class=${`input${f.mono ? ' mono' : ''}`}
|
|
155
|
-
placeholder=${f.placeholder || ''}
|
|
156
|
-
value=${draft[f.key] || ''}
|
|
157
|
-
onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
|
|
158
|
-
autoFocus=${f.autoFocus} />`}
|
|
159
|
-
${f.hint ? html`<span class="picker-field-hint">${f.hint}</span>` : null}
|
|
160
|
-
</label>`)}
|
|
161
|
-
<div class="picker-create-actions">
|
|
162
|
-
<button type="button" class="action small subtle"
|
|
163
|
-
onClick=${() => { setCreating(false); setDraft(initialDraft(createFields)); }}>
|
|
164
|
-
Cancel
|
|
165
|
-
</button>
|
|
166
|
-
<button type="submit" class="action small primary" disabled=${saving}>
|
|
167
|
-
${saving ? 'Creating…' : 'Create'}
|
|
168
|
-
</button>
|
|
169
|
-
</div>
|
|
170
|
-
</form>`}
|
|
171
|
-
</div>` : null}
|
|
172
|
-
</div>`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function initialDraft(fields) {
|
|
176
|
-
const out = {};
|
|
177
|
-
for (const f of fields) out[f.key] = f.default ?? '';
|
|
178
|
-
return out;
|
|
179
|
-
}
|
|
1
|
+
// Unified picker used inside a Popover. Renders:
|
|
2
|
+
// - optional search box (filters items by `label` + `meta`)
|
|
3
|
+
// - scrollable item list (single or multi select)
|
|
4
|
+
// - footer "+ New <thing>" that expands an inline form
|
|
5
|
+
//
|
|
6
|
+
// items: [{ id, label, meta?, disabled? }]
|
|
7
|
+
// onSelect(id) — single select. Closes popover.
|
|
8
|
+
// onToggle(id, on) — multi select. Doesn't close.
|
|
9
|
+
// selectedIds: Set<string> — for multi select highlight
|
|
10
|
+
// selectedId: string — for single select highlight
|
|
11
|
+
// onCreate(values) — async; returns the newly-created id (selected immediately
|
|
12
|
+
// in single mode; added to selection in multi mode)
|
|
13
|
+
// createFields: [{ key, label, type?, placeholder?, required? }]
|
|
14
|
+
|
|
15
|
+
import { html } from '../html.js';
|
|
16
|
+
import { useState, useRef, useEffect } from 'preact/hooks';
|
|
17
|
+
import { IconSearch, IconPlus, IconClose } from '../icons.js';
|
|
18
|
+
|
|
19
|
+
export function PickerPanel({
|
|
20
|
+
title,
|
|
21
|
+
items,
|
|
22
|
+
selectedId,
|
|
23
|
+
selectedIds,
|
|
24
|
+
multi = false,
|
|
25
|
+
showSearch = true,
|
|
26
|
+
emptyHint = 'Nothing yet.',
|
|
27
|
+
createLabel = '+ New',
|
|
28
|
+
createFields = [],
|
|
29
|
+
onSelect,
|
|
30
|
+
onToggle,
|
|
31
|
+
onCreate,
|
|
32
|
+
onClose,
|
|
33
|
+
dnd,
|
|
34
|
+
}) {
|
|
35
|
+
const [q, setQ] = useState('');
|
|
36
|
+
const [creating, setCreating] = useState(false);
|
|
37
|
+
const [draft, setDraft] = useState(() => initialDraft(createFields));
|
|
38
|
+
const [saving, setSaving] = useState(false);
|
|
39
|
+
const searchRef = useRef(null);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (showSearch) searchRef.current?.focus();
|
|
43
|
+
}, [showSearch]);
|
|
44
|
+
|
|
45
|
+
const filtered = items.filter((it) => {
|
|
46
|
+
if (!q.trim()) return true;
|
|
47
|
+
const hay = (it.label + ' ' + (it.meta || '')).toLowerCase();
|
|
48
|
+
return hay.includes(q.trim().toLowerCase());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const submitCreate = async (ev) => {
|
|
52
|
+
ev?.preventDefault?.();
|
|
53
|
+
for (const f of createFields) {
|
|
54
|
+
if (f.required && !String(draft[f.key] || '').trim()) return;
|
|
55
|
+
}
|
|
56
|
+
setSaving(true);
|
|
57
|
+
try {
|
|
58
|
+
const id = await onCreate?.(draft);
|
|
59
|
+
setDraft(initialDraft(createFields));
|
|
60
|
+
setCreating(false);
|
|
61
|
+
if (id != null) {
|
|
62
|
+
if (multi) onToggle?.(id, true);
|
|
63
|
+
else { onSelect?.(id); onClose?.(); }
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Caller is expected to toast; we just stay open with the form
|
|
67
|
+
console.warn(e);
|
|
68
|
+
} finally {
|
|
69
|
+
setSaving(false);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return html`
|
|
74
|
+
<div class="picker">
|
|
75
|
+
${title ? html`<div class="picker-title">${title}</div>` : null}
|
|
76
|
+
|
|
77
|
+
${showSearch ? html`
|
|
78
|
+
<div class="picker-search">
|
|
79
|
+
<span class="picker-search-icon"><${IconSearch} /></span>
|
|
80
|
+
<input ref=${searchRef} class="picker-search-input"
|
|
81
|
+
placeholder="Search…" value=${q}
|
|
82
|
+
onInput=${(e) => setQ(e.target.value)} />
|
|
83
|
+
${q ? html`<button class="picker-search-clear" onClick=${() => setQ('')}>
|
|
84
|
+
<${IconClose} />
|
|
85
|
+
</button>` : null}
|
|
86
|
+
</div>` : null}
|
|
87
|
+
|
|
88
|
+
<div class="picker-list">
|
|
89
|
+
${filtered.length === 0 ? html`
|
|
90
|
+
<div class="picker-empty">${q ? 'No matches.' : emptyHint}</div>
|
|
91
|
+
` : filtered.map((it) => {
|
|
92
|
+
const isSel = multi ? selectedIds?.has(it.id) : selectedId === it.id;
|
|
93
|
+
const enableDnd = dnd && !it.disabled && !it.undraggable && !q.trim();
|
|
94
|
+
const rowProps = enableDnd ? dnd.rowProps(it.id) : {};
|
|
95
|
+
const handleProps = enableDnd ? dnd.handleProps(it.id) : {};
|
|
96
|
+
return html`
|
|
97
|
+
<div key=${it.id} class=${`picker-item-wrap${enableDnd ? ' is-draggable' : ''}`} ...${rowProps} ...${handleProps}>
|
|
98
|
+
${enableDnd ? html`<span class="picker-item-grip" aria-hidden="true">⋮⋮</span>` : null}
|
|
99
|
+
<button type="button"
|
|
100
|
+
class=${`picker-item${isSel ? ' is-selected' : ''}`}
|
|
101
|
+
disabled=${it.disabled}
|
|
102
|
+
onClick=${() => {
|
|
103
|
+
if (multi) onToggle?.(it.id, !isSel);
|
|
104
|
+
else { onSelect?.(it.id); onClose?.(); }
|
|
105
|
+
}}>
|
|
106
|
+
${it.icon ? html`<span class="picker-item-icon">${it.icon}</span>` : null}
|
|
107
|
+
<span class="picker-item-label">${it.label}</span>
|
|
108
|
+
${it.meta ? html`<span class="picker-item-meta">${it.meta}</span>` : null}
|
|
109
|
+
${isSel ? html`<span class="picker-item-check">✓</span>` : null}
|
|
110
|
+
</button>
|
|
111
|
+
</div>`;
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
${onCreate ? html`
|
|
116
|
+
<div class="picker-create">
|
|
117
|
+
${!creating ? html`
|
|
118
|
+
<button class="picker-create-toggle" type="button"
|
|
119
|
+
onClick=${() => setCreating(true)}>
|
|
120
|
+
<${IconPlus} />
|
|
121
|
+
<span>${createLabel}</span>
|
|
122
|
+
</button>
|
|
123
|
+
` : html`
|
|
124
|
+
<form class="picker-create-form" onSubmit=${submitCreate}>
|
|
125
|
+
${createFields.map((f) => html`
|
|
126
|
+
<label class="picker-field" key=${f.key}>
|
|
127
|
+
<span class="picker-field-label">${f.label}</span>
|
|
128
|
+
${f.type === 'select' ? html`
|
|
129
|
+
<select class="input" value=${draft[f.key] || ''}
|
|
130
|
+
onChange=${(e) => {
|
|
131
|
+
const next = { ...draft, [f.key]: e.target.value };
|
|
132
|
+
const side = f.onChange?.(e.target.value, next);
|
|
133
|
+
setDraft(side ? { ...next, ...side } : next);
|
|
134
|
+
}}>
|
|
135
|
+
${(f.options || []).map((opt) => html`
|
|
136
|
+
<option value=${opt.value}>${opt.label}</option>`)}
|
|
137
|
+
</select>
|
|
138
|
+
` : f.type === 'iconRadio' ? html`
|
|
139
|
+
<div class="icon-radio">
|
|
140
|
+
${(f.options || []).map((opt) => html`
|
|
141
|
+
<button type="button" key=${opt.value}
|
|
142
|
+
class=${`icon-radio-opt${draft[f.key] === opt.value ? ' is-active' : ''}`}
|
|
143
|
+
onClick=${() => {
|
|
144
|
+
const next = { ...draft, [f.key]: opt.value };
|
|
145
|
+
const side = f.onChange?.(opt.value, next);
|
|
146
|
+
setDraft(side ? { ...next, ...side } : next);
|
|
147
|
+
}}>
|
|
148
|
+
${opt.icon ? html`<span class="icon-radio-icon">${opt.icon}</span>` : null}
|
|
149
|
+
<span>${opt.label}</span>
|
|
150
|
+
</button>`)}
|
|
151
|
+
</div>
|
|
152
|
+
` : html`
|
|
153
|
+
<input type=${f.type || 'text'}
|
|
154
|
+
class=${`input${f.mono ? ' mono' : ''}`}
|
|
155
|
+
placeholder=${f.placeholder || ''}
|
|
156
|
+
value=${draft[f.key] || ''}
|
|
157
|
+
onInput=${(e) => setDraft({ ...draft, [f.key]: e.target.value })}
|
|
158
|
+
autoFocus=${f.autoFocus} />`}
|
|
159
|
+
${f.hint ? html`<span class="picker-field-hint">${f.hint}</span>` : null}
|
|
160
|
+
</label>`)}
|
|
161
|
+
<div class="picker-create-actions">
|
|
162
|
+
<button type="button" class="action small subtle"
|
|
163
|
+
onClick=${() => { setCreating(false); setDraft(initialDraft(createFields)); }}>
|
|
164
|
+
Cancel
|
|
165
|
+
</button>
|
|
166
|
+
<button type="submit" class="action small primary" disabled=${saving}>
|
|
167
|
+
${saving ? 'Creating…' : 'Create'}
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</form>`}
|
|
171
|
+
</div>` : null}
|
|
172
|
+
</div>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function initialDraft(fields) {
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const f of fields) out[f.key] = f.default ?? '';
|
|
178
|
+
return out;
|
|
179
|
+
}
|