anentrypoint-design 0.0.170 → 0.0.172
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/app-shell.css +2 -0
- package/community.css +1 -1
- package/dist/247420.css +94 -1
- package/dist/247420.js +12 -12
- package/package.json +3 -1
- package/src/community-app.js +22 -6
- package/src/components/agent-chat.js +5 -25
- package/src/components/chat.js +49 -73
- package/src/components/community.js +16 -5
- package/src/components/content.js +4 -4
- package/src/components/editor-primitives.js +2 -1
- package/src/components/files-modals.js +3 -3
- package/src/components/files.js +11 -11
- package/src/components/freddie/runtime.js +12 -4
- package/src/components/freddie.js +6 -6
- package/src/components/overlay-primitives.js +10 -3
- package/src/components/shell.js +37 -4
- package/src/components/voice.js +3 -3
- package/src/index.js +1 -0
- package/src/kits/os/freddie/helpers.js +2 -2
- package/src/kits/os/freddie/pages-chat.js +3 -3
- package/src/kits/os/freddie/pages-core.js +4 -4
- package/src/kits/os/freddie/pages-os.js +7 -7
- package/src/kits/os/freddie/pages-tools.js +10 -10
- package/src/kits/os/freddie/routes.js +21 -19
- package/src/kits/os/freddie-dashboard.js +2 -2
- package/src/kits/spoint/host-join-lobby.css +89 -0
- package/src/kits/spoint/host-join-lobby.js +81 -0
- package/src/kits/spoint/index.js +2 -0
- package/src/markdown-cache.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.172",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"./kits/spoint/loading-screen.css": "./src/kits/spoint/loading-screen.css",
|
|
20
20
|
"./kits/spoint/game-hud.js": "./src/kits/spoint/game-hud.js",
|
|
21
21
|
"./kits/spoint/game-hud.css": "./src/kits/spoint/game-hud.css",
|
|
22
|
+
"./kits/spoint/host-join-lobby.js": "./src/kits/spoint/host-join-lobby.js",
|
|
23
|
+
"./kits/spoint/host-join-lobby.css": "./src/kits/spoint/host-join-lobby.css",
|
|
22
24
|
"./kits/os": {
|
|
23
25
|
"import": "./src/kits/os/index.js",
|
|
24
26
|
"default": "./src/kits/os/index.js"
|
package/src/community-app.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import * as webjsx from '../vendor/webjsx/index.js';
|
|
31
31
|
import { Icon } from './components/shell.js';
|
|
32
|
+
import { register } from './debug.js';
|
|
32
33
|
import { Chat, ChatComposer } from './components/chat.js';
|
|
33
34
|
import {
|
|
34
35
|
ServerRail, ChannelItem, MemberList, MobileHeader,
|
|
@@ -85,11 +86,11 @@ export function mountCommunityApp(root, adapter = {}) {
|
|
|
85
86
|
const railPill = (c, cur, isVoice, s) => {
|
|
86
87
|
const active = cur.id === c.id;
|
|
87
88
|
const inVoice = isVoice && s.voiceConnected && s.voiceChannelName === c.name;
|
|
88
|
-
const glyph = inVoice ? h('span', { class: 'glyph' }, '
|
|
89
|
-
: (c.type === 'threaded' ? h('span', { class: 'glyph' }, '
|
|
90
|
-
: h('span', { class: 'glyph' }, Icon(CHANNEL_ICON[c.type] || 'hash', { size: 15 })));
|
|
89
|
+
const glyph = inVoice ? h('span', { class: 'glyph', 'aria-hidden': 'true' }, h('span', { class: 'ds-dot ds-dot-live' }))
|
|
90
|
+
: (c.type === 'threaded' ? h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('circle-dot', { size: 15 }))
|
|
91
|
+
: h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(CHANNEL_ICON[c.type] || 'hash', { size: 15 })));
|
|
91
92
|
return h('a', {
|
|
92
|
-
href: '#', class: active ? 'active' : '',
|
|
93
|
+
href: '#', class: active ? 'active' : '', 'aria-label': (c.name || c.id) + (inVoice ? ' (in voice)' : ''),
|
|
93
94
|
onclick: (e) => { e.preventDefault(); A.switchChannel && A.switchChannel(c); },
|
|
94
95
|
oncontextmenu: (e) => { e.preventDefault(); A.channelContext && A.channelContext(c.id, e.clientX, e.clientY); },
|
|
95
96
|
}, glyph, h('span', {}, c.name || c.id),
|
|
@@ -99,10 +100,10 @@ export function mountCommunityApp(root, adapter = {}) {
|
|
|
99
100
|
const railServerPill = (sv, s) => {
|
|
100
101
|
const active = sv._home ? s.homeMode : (!s.homeMode && s.currentServerId === sv.id);
|
|
101
102
|
return h('a', {
|
|
102
|
-
href: '#', class: active ? 'active' : '',
|
|
103
|
+
href: '#', class: active ? 'active' : '', 'aria-label': sv._home ? 'home' : (sv.name || sv.id),
|
|
103
104
|
onclick: (e) => { e.preventDefault(); sv._home ? (A.goHome && A.goHome()) : (A.switchServer && A.switchServer(sv.id)); },
|
|
104
105
|
oncontextmenu: sv._home ? null : (e) => { e.preventDefault(); A.serverContext && A.serverContext(sv.id, e.clientX, e.clientY); },
|
|
105
|
-
}, h('span', { class: 'glyph' }, sv._home ? '
|
|
106
|
+
}, h('span', { class: 'glyph', 'aria-hidden': 'true' }, sv._home ? Icon('square', { size: 15 }) : (sv.name || '?').slice(0, 1).toUpperCase()),
|
|
106
107
|
h('span', {}, sv.name || sv.id),
|
|
107
108
|
sv.unreadCount ? h('span', { class: 'count' }, sv.unreadCount > 99 ? '99+' : String(sv.unreadCount)) : null);
|
|
108
109
|
};
|
|
@@ -228,6 +229,21 @@ export function mountCommunityApp(root, adapter = {}) {
|
|
|
228
229
|
|
|
229
230
|
let unsub = null;
|
|
230
231
|
if (typeof adapter.subscribe === 'function') unsub = adapter.subscribe(render);
|
|
232
|
+
|
|
233
|
+
// Observability: expose live overlay + snapshot state for in-browser inspection.
|
|
234
|
+
register('community-app', () => {
|
|
235
|
+
const s = get() || {};
|
|
236
|
+
return {
|
|
237
|
+
overlays: { context: ctx.open, emoji: emoji.open, palette: palette.open },
|
|
238
|
+
channels: (s.channels || []).length,
|
|
239
|
+
servers: (s.servers || []).length,
|
|
240
|
+
messages: (s.messages || []).length,
|
|
241
|
+
currentChannel: (s.currentChannel || {}).name || null,
|
|
242
|
+
voiceConnected: !!s.voiceConnected,
|
|
243
|
+
homeMode: !!s.homeMode,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
|
|
231
247
|
render();
|
|
232
248
|
return { render, api, destroy: () => { if (unsub) try { unsub(); } catch (_) {} } };
|
|
233
249
|
}
|
|
@@ -11,36 +11,16 @@
|
|
|
11
11
|
// The host owns state; AgentChat renders it and calls back on intent.
|
|
12
12
|
|
|
13
13
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
14
|
-
import { ChatComposer, ChatMessage } from './chat.js';
|
|
14
|
+
import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
|
|
15
15
|
import { Select } from './content.js';
|
|
16
16
|
import { Btn } from './shell.js';
|
|
17
17
|
|
|
18
18
|
const h = webjsx.createElement;
|
|
19
19
|
|
|
20
|
-
// Auto-scroll
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
return (el) => {
|
|
25
|
-
if (!el) return;
|
|
26
|
-
let sentinel = el.querySelector('[data-scroll-sentinel]');
|
|
27
|
-
if (!sentinel) {
|
|
28
|
-
sentinel = document.createElement('div');
|
|
29
|
-
sentinel.setAttribute('data-scroll-sentinel', '');
|
|
30
|
-
sentinel.style.height = '1px';
|
|
31
|
-
el.appendChild(sentinel);
|
|
32
|
-
}
|
|
33
|
-
const obs = new IntersectionObserver((entries) => {
|
|
34
|
-
if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(msgCount)) {
|
|
35
|
-
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
36
|
-
el.dataset.msgCount = String(msgCount);
|
|
37
|
-
}
|
|
38
|
-
}, { root: el, threshold: 0 });
|
|
39
|
-
obs.observe(sentinel);
|
|
40
|
-
el.dataset.msgCount = String(msgCount);
|
|
41
|
-
return () => obs.disconnect();
|
|
42
|
-
};
|
|
43
|
-
}
|
|
20
|
+
// Auto-scroll behaviour is the shared chat helper; bind it to this thread's
|
|
21
|
+
// live message count. (`makeThreadAutoScroll` takes a getter so the observer
|
|
22
|
+
// always compares against current state, not a value captured at mount.)
|
|
23
|
+
const threadRef = (msgCount) => makeThreadAutoScroll(() => msgCount);
|
|
44
24
|
|
|
45
25
|
// The agent picker: agent-then-model, not a flat model list. Unavailable agents
|
|
46
26
|
// are disabled (unless installable via npx). Ordering is the host's concern.
|
package/src/components/chat.js
CHANGED
|
@@ -38,10 +38,45 @@ export function renderInline(text) {
|
|
|
38
38
|
return out;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
|
|
42
|
+
const FILE_ICONS = { pdf: 'file-pdf', zip: 'file-zip', tar: 'file-zip', gz: 'file-zip', mp4: 'file-video', mov: 'file-video', mp3: 'file-audio', wav: 'file-audio', csv: 'file-sheet', json: 'file-code', js: 'file-code', ts: 'file-code', md: 'file-text', txt: 'file-text' };
|
|
43
|
+
function fileIconName(name) {
|
|
43
44
|
const ext = String(name || '').split('.').pop().toLowerCase();
|
|
44
|
-
return
|
|
45
|
+
return FILE_ICONS[ext] || 'file';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Eagerly warm the markdown + Prism caches on first chat-surface mount, once.
|
|
49
|
+
function ensureCachesInit() {
|
|
50
|
+
if (_cacheInitialized) return;
|
|
51
|
+
_cacheInitialized = true;
|
|
52
|
+
initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build a ref callback that keeps a scroll container pinned to the bottom when
|
|
56
|
+
// new messages arrive AND the user is already at the bottom (sentinel visible).
|
|
57
|
+
// `getCount` returns the current message count so the observer compares against
|
|
58
|
+
// live state. Shared by Chat, AICat, and AgentChat.
|
|
59
|
+
export function makeThreadAutoScroll(getCount) {
|
|
60
|
+
return (el) => {
|
|
61
|
+
if (!el) return;
|
|
62
|
+
let sentinel = el.querySelector('[data-scroll-sentinel]');
|
|
63
|
+
if (!sentinel) {
|
|
64
|
+
sentinel = document.createElement('div');
|
|
65
|
+
sentinel.setAttribute('data-scroll-sentinel', '');
|
|
66
|
+
sentinel.style.height = '1px';
|
|
67
|
+
el.appendChild(sentinel);
|
|
68
|
+
}
|
|
69
|
+
const obs = new IntersectionObserver((entries) => {
|
|
70
|
+
const count = String(getCount());
|
|
71
|
+
if (entries[0]?.isIntersecting && el.dataset.msgCount !== count) {
|
|
72
|
+
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
73
|
+
el.dataset.msgCount = count;
|
|
74
|
+
}
|
|
75
|
+
}, { root: el, threshold: 0 });
|
|
76
|
+
obs.observe(sentinel);
|
|
77
|
+
el.dataset.msgCount = String(getCount());
|
|
78
|
+
return () => obs.disconnect();
|
|
79
|
+
};
|
|
45
80
|
}
|
|
46
81
|
|
|
47
82
|
function MdNode(p) {
|
|
@@ -79,19 +114,19 @@ const PART_RENDERERS = {
|
|
|
79
114
|
p.caption ? h('span', { class: 'cap' }, p.caption) : null),
|
|
80
115
|
pdf: (p) => h('div', { class: 'chat-pdf' },
|
|
81
116
|
h('div', { class: 'chat-pdf-head' },
|
|
82
|
-
h('span', { class: 'glyph', 'aria-hidden': 'true' }, '
|
|
117
|
+
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
|
|
83
118
|
h('span', { class: 'name' }, p.name || 'document.pdf'),
|
|
84
119
|
p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
|
|
85
120
|
h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
|
|
86
121
|
),
|
|
87
122
|
h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
|
|
88
123
|
file: (p) => h('a', { class: 'chat-file', href: p.src, target: '_blank', rel: 'noopener', download: p.name || true, 'aria-label': `download file: ${p.name || 'attachment'} (${p.kindLabel || (p.name || '').split('.').pop().toUpperCase()})` },
|
|
89
|
-
h('span', { class: 'glyph', 'aria-hidden': 'true' },
|
|
124
|
+
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
|
|
90
125
|
h('span', { class: 'meta' },
|
|
91
126
|
h('span', { class: 'name' }, p.name || 'attachment'),
|
|
92
127
|
h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
|
|
93
128
|
),
|
|
94
|
-
h('span', { class: 'go', 'aria-hidden': 'true' }, '
|
|
129
|
+
h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
|
|
95
130
|
link: (p) => h('a', { class: 'chat-link', href: p.href, target: '_blank', rel: 'noopener', 'aria-label': `link: ${p.title || p.href}` },
|
|
96
131
|
p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
|
|
97
132
|
h('span', { class: 'meta' },
|
|
@@ -116,7 +151,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
116
151
|
const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '');
|
|
117
152
|
const fallbackAvatar = avatar != null
|
|
118
153
|
? avatar
|
|
119
|
-
: (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '
|
|
154
|
+
: (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
|
|
120
155
|
const av = h('span', { class: 'chat-avatar' }, fallbackAvatar);
|
|
121
156
|
let bodyNodes;
|
|
122
157
|
if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
|
|
@@ -128,7 +163,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
128
163
|
h('span', { class: 'e', 'aria-hidden': 'true' }, r.emoji), h('span', { class: 'n', 'aria-hidden': 'true' }, String(r.count)))))
|
|
129
164
|
: null;
|
|
130
165
|
const tickNode = resolvedWho === 'you' && receipt
|
|
131
|
-
? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, receipt === 'read' ? '
|
|
166
|
+
? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), role: 'img', 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, Icon(receipt === 'read' ? 'check-check' : 'check', { size: 14 }))
|
|
132
167
|
: null;
|
|
133
168
|
const metaItems = [];
|
|
134
169
|
if (name && resolvedWho === 'them') metaItems.push(h('span', { class: 'who', key: 'w' }, name));
|
|
@@ -191,40 +226,9 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
export function Chat({ title = 'chat', sub, messages = [], composer, header } = {}) {
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
_cacheInitialized = true;
|
|
198
|
-
initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const threadRef = (el) => {
|
|
202
|
-
if (!el) return;
|
|
203
|
-
const sentinel = el.querySelector('[data-scroll-sentinel]') || (() => {
|
|
204
|
-
const s = h('div', { 'data-scroll-sentinel': true });
|
|
205
|
-
const vnode = { props: s.props };
|
|
206
|
-
const sentinelEl = document.createElement('div');
|
|
207
|
-
sentinelEl.setAttribute('data-scroll-sentinel', '');
|
|
208
|
-
sentinelEl.style.height = '1px';
|
|
209
|
-
el.appendChild(sentinelEl);
|
|
210
|
-
return sentinelEl;
|
|
211
|
-
})();
|
|
212
|
-
|
|
213
|
-
// Use IntersectionObserver to detect if user is at bottom
|
|
214
|
-
const obs = new IntersectionObserver(
|
|
215
|
-
(entries) => {
|
|
216
|
-
if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(messages.length)) {
|
|
217
|
-
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
218
|
-
el.dataset.msgCount = String(messages.length);
|
|
219
|
-
}
|
|
220
|
-
},
|
|
221
|
-
{ root: el, threshold: 0 }
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
obs.observe(sentinel);
|
|
225
|
-
el.dataset.msgCount = String(messages.length);
|
|
226
|
-
return () => obs.disconnect();
|
|
227
|
-
};
|
|
229
|
+
// Warm markdown/Prism caches once so library loading parallelizes.
|
|
230
|
+
ensureCachesInit();
|
|
231
|
+
const threadRef = makeThreadAutoScroll(() => messages.length);
|
|
228
232
|
const msgCount = messages.length;
|
|
229
233
|
return h('div', { class: 'chat' },
|
|
230
234
|
header || h('div', { class: 'chat-head', role: 'banner' },
|
|
@@ -255,47 +259,19 @@ export function AICatPortrait({ name = 'aicat', status = 'idle', face } = {}) {
|
|
|
255
259
|
h('pre', { class: 'aicat-face', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
|
|
256
260
|
h('div', { class: 'aicat-meta' },
|
|
257
261
|
h('span', { class: 'name' }, name),
|
|
258
|
-
h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot', 'aria-hidden': 'true' }, '
|
|
262
|
+
h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot ds-dot ds-dot-on', 'aria-hidden': 'true' }), ' ', status)
|
|
259
263
|
)
|
|
260
264
|
);
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
export function AICat({ name = 'aicat', messages = [], thinking, composer, status = 'online · purring' } = {}) {
|
|
264
|
-
|
|
265
|
-
if (!_cacheInitialized) {
|
|
266
|
-
_cacheInitialized = true;
|
|
267
|
-
initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
|
|
268
|
-
}
|
|
269
|
-
|
|
268
|
+
ensureCachesInit();
|
|
270
269
|
const annotated = messages.map((m) =>
|
|
271
270
|
m.who === 'them' ? { ...m, aicat: true, avatar: m.avatar || '=^.^=' } : m);
|
|
272
271
|
const all = thinking
|
|
273
272
|
? [...annotated, { who: 'them', aicat: true, avatar: '=^.^=', typing: true, key: '_thinking' }]
|
|
274
273
|
: annotated;
|
|
275
|
-
const threadRef = (
|
|
276
|
-
if (!el) return;
|
|
277
|
-
const sentinel = el.querySelector('[data-scroll-sentinel]') || (() => {
|
|
278
|
-
const sentinelEl = document.createElement('div');
|
|
279
|
-
sentinelEl.setAttribute('data-scroll-sentinel', '');
|
|
280
|
-
sentinelEl.style.height = '1px';
|
|
281
|
-
el.appendChild(sentinelEl);
|
|
282
|
-
return sentinelEl;
|
|
283
|
-
})();
|
|
284
|
-
|
|
285
|
-
const obs = new IntersectionObserver(
|
|
286
|
-
(entries) => {
|
|
287
|
-
if (entries[0]?.isIntersecting && el.dataset.msgCount !== String(all.length)) {
|
|
288
|
-
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
289
|
-
el.dataset.msgCount = String(all.length);
|
|
290
|
-
}
|
|
291
|
-
},
|
|
292
|
-
{ root: el, threshold: 0 }
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
obs.observe(sentinel);
|
|
296
|
-
el.dataset.msgCount = String(all.length);
|
|
297
|
-
return () => obs.disconnect();
|
|
298
|
-
};
|
|
274
|
+
const threadRef = makeThreadAutoScroll(() => all.length);
|
|
299
275
|
return h('div', { class: 'chat' },
|
|
300
276
|
h('div', { class: 'chat-head', role: 'banner' },
|
|
301
277
|
h('span', { class: 'dot', 'aria-hidden': 'true' }),
|
|
@@ -4,6 +4,10 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
4
4
|
import { Icon } from './shell.js';
|
|
5
5
|
const h = webjsx.createElement;
|
|
6
6
|
|
|
7
|
+
// Clamp a count to a compact badge string (matches the rail's 99+ convention),
|
|
8
|
+
// so a runaway number never blows out a fixed-width badge or item row.
|
|
9
|
+
const clampCount = (n) => { const v = Number(n) || 0; return v > 99 ? '99+' : String(v); };
|
|
10
|
+
|
|
7
11
|
export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
|
|
8
12
|
const initials = (name || '?').slice(0, 2).toUpperCase();
|
|
9
13
|
return h('div', {
|
|
@@ -29,7 +33,7 @@ export function ServerIcon({ id, name, icon, active, badge, onClick } = {}) {
|
|
|
29
33
|
|
|
30
34
|
export function ServerRail({ servers = [], activeId, onSelect, onAdd } = {}) {
|
|
31
35
|
return h('div', { class: 'cm-server-rail', role: 'navigation', 'aria-label': 'servers' },
|
|
32
|
-
h('a', { class: 'cm-server-back', href: '../', title: 'Back', 'aria-label': 'back' }, '
|
|
36
|
+
h('a', { class: 'cm-server-back', href: '../', title: 'Back', 'aria-label': 'back' }, Icon('chevron-left')),
|
|
33
37
|
h('div', { class: 'cm-server-sep', 'aria-hidden': 'true' }),
|
|
34
38
|
...servers.map(s => ServerIcon({ ...s, active: s.id === activeId, onClick: () => onSelect && onSelect(s.id) })),
|
|
35
39
|
onAdd ? h('button', { class: 'cm-server-add', type: 'button', onclick: onAdd, title: 'Add server', 'aria-label': 'add server' }, '+') : null
|
|
@@ -161,6 +165,9 @@ export function ChannelSidebar({ serverName, channels = [], categories = [], act
|
|
|
161
165
|
h('span', { class: 'cm-server-header-name' }, serverName || 'Server'),
|
|
162
166
|
),
|
|
163
167
|
h('div', { class: 'cm-channel-list' },
|
|
168
|
+
(sorted.length === 0 && uncategorized.length === 0)
|
|
169
|
+
? h('div', { class: 'cm-channel-empty' }, 'no channels yet')
|
|
170
|
+
: null,
|
|
164
171
|
...sorted.map(cat => ChannelCategory({
|
|
165
172
|
id: cat.id,
|
|
166
173
|
name: cat.name,
|
|
@@ -197,7 +204,11 @@ export function MemberItem({ identity, name, color, status = 'online' } = {}) {
|
|
|
197
204
|
}
|
|
198
205
|
|
|
199
206
|
export function MemberList({ categories = [], open } = {}) {
|
|
207
|
+
const total = categories.reduce((n, cat) => n + (cat.members ? cat.members.length : 0), 0);
|
|
200
208
|
return h('div', { class: 'cm-member-list' + (open ? ' open' : '') },
|
|
209
|
+
total === 0
|
|
210
|
+
? h('div', { key: '_empty', class: 'cm-member-empty' }, 'no members')
|
|
211
|
+
: null,
|
|
201
212
|
...categories.flatMap(cat => [
|
|
202
213
|
h('div', { class: 'cm-member-category', key: cat.label }, `${cat.label} — ${cat.members.length}`),
|
|
203
214
|
...cat.members.map((m, i) => MemberItem({ ...m, key: m.identity || i }))
|
|
@@ -235,7 +246,7 @@ export function VoiceStrip({ channelName, status, muted, deafened, onMute, onDea
|
|
|
235
246
|
h('button', {
|
|
236
247
|
class: 'cm-vs-btn danger', type: 'button', onclick: onLeave,
|
|
237
248
|
title: 'Leave voice', 'aria-label': 'leave voice channel'
|
|
238
|
-
}, '
|
|
249
|
+
}, Icon('x'))
|
|
239
250
|
);
|
|
240
251
|
}
|
|
241
252
|
|
|
@@ -266,7 +277,7 @@ export function ReplyBar({ quotedMessage, quotedAuthor, onCancel } = {}) {
|
|
|
266
277
|
h('button', {
|
|
267
278
|
class: 'cm-rb-cancel', type: 'button', onclick: onCancel,
|
|
268
279
|
title: 'Cancel reply', 'aria-label': 'cancel reply'
|
|
269
|
-
}, '
|
|
280
|
+
}, Icon('x'))
|
|
270
281
|
);
|
|
271
282
|
}
|
|
272
283
|
|
|
@@ -305,7 +316,7 @@ export function ThreadPanel({ threads = [], activeId = null, title = 'Threads',
|
|
|
305
316
|
h('span', { class: 'cm-tp-title' }, title),
|
|
306
317
|
h('div', { class: 'cm-tp-head-actions' },
|
|
307
318
|
onCreate ? h('button', { type: 'button', class: 'cm-tp-new', 'aria-label': 'new thread', title: 'New thread', onclick: onCreate }, '+') : null,
|
|
308
|
-
onClose ? h('button', { type: 'button', class: 'cm-tp-close', 'aria-label': 'close', title: 'Close', onclick: onClose }, '
|
|
319
|
+
onClose ? h('button', { type: 'button', class: 'cm-tp-close', 'aria-label': 'close', title: 'Close', onclick: onClose }, Icon('x')) : null
|
|
309
320
|
)
|
|
310
321
|
),
|
|
311
322
|
h('div', { class: 'cm-tp-list' },
|
|
@@ -355,7 +366,7 @@ export function ForumView({ posts = [], onSearch, onSort, onSelect, onNewPost }
|
|
|
355
366
|
},
|
|
356
367
|
h('div', { class: 'cm-forum-item-head' },
|
|
357
368
|
h('span', { class: 'cm-forum-item-title' }, p.title || '(untitled)'),
|
|
358
|
-
h('span', { class: 'cm-forum-item-replies' }, (
|
|
369
|
+
h('span', { class: 'cm-forum-item-replies' }, clampCount(p.replyCount), Icon('chevron-right', { size: 13 }))
|
|
359
370
|
),
|
|
360
371
|
p.snippet ? h('div', { class: 'cm-forum-item-snippet' }, p.snippet) : null,
|
|
361
372
|
h('div', { class: 'cm-forum-item-meta' },
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ProjectView, Form. Pure factories.
|
|
4
4
|
|
|
5
5
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
6
|
-
import { Btn, Heading, Lede, Dot } from './shell.js';
|
|
6
|
+
import { Btn, Heading, Lede, Dot, Icon } from './shell.js';
|
|
7
7
|
const h = webjsx.createElement;
|
|
8
8
|
|
|
9
9
|
export function Panel({ title, count, right, style = '', children, kind }) {
|
|
@@ -351,14 +351,14 @@ export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'l
|
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
|
|
354
|
-
const icons = { info: '
|
|
354
|
+
const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
|
|
355
355
|
const cls = 'ds-alert ds-alert-' + kind;
|
|
356
356
|
return h('div', { key, class: cls, role: 'alert' },
|
|
357
|
-
h('span', { key: 'icon', class: 'ds-alert-icon' }, icons[kind]),
|
|
357
|
+
h('span', { key: 'icon', class: 'ds-alert-icon' }, Icon(icons[kind] || 'info')),
|
|
358
358
|
h('div', { key: 'content', class: 'ds-alert-content' },
|
|
359
359
|
title ? h('div', { key: 'title', class: 'ds-alert-title' }, title) : null,
|
|
360
360
|
h('div', { key: 'msg', class: 'ds-alert-message' }, ...(Array.isArray(children) ? children : [children]))
|
|
361
361
|
),
|
|
362
|
-
onDismiss ? h('button', { key: 'dismiss', class: 'ds-alert-dismiss', onclick: onDismiss }, '
|
|
362
|
+
onDismiss ? h('button', { key: 'dismiss', class: 'ds-alert-dismiss', 'aria-label': 'dismiss', onclick: onDismiss }, Icon('x')) : null
|
|
363
363
|
);
|
|
364
364
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// via the kit's data-theme attribute on the .ds-247420 scope root.
|
|
6
6
|
|
|
7
7
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
8
|
+
import { Icon } from './shell.js';
|
|
8
9
|
const h = webjsx.createElement;
|
|
9
10
|
|
|
10
11
|
function kids(c) { return c == null ? [] : (Array.isArray(c) ? c : [c]); }
|
|
@@ -122,7 +123,7 @@ export function TreeItem({ label, glyph, tag, depth = 0, selected = false, expan
|
|
|
122
123
|
class: 'ds-ep-tree-twist' + (expanded ? ' open' : ''),
|
|
123
124
|
'aria-hidden': 'true',
|
|
124
125
|
onclick: (e) => { e.stopPropagation(); if (hasKids && onToggle) onToggle(); }
|
|
125
|
-
}, hasKids ? '
|
|
126
|
+
}, hasKids ? Icon('chevron-right') : ''),
|
|
126
127
|
glyph != null ? h('span', { class: 'ds-ep-tree-glyph', 'aria-hidden': 'true' }, glyph) : null,
|
|
127
128
|
h('span', { class: 'ds-ep-tree-label' }, label),
|
|
128
129
|
tag != null ? h('span', { class: 'ds-ep-tree-tag' }, tag) : null
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// File modals — matches upstream signatures + class names.
|
|
2
2
|
|
|
3
3
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
4
|
-
import { Btn } from './shell.js';
|
|
4
|
+
import { Btn, Icon } from './shell.js';
|
|
5
5
|
import { fileGlyph, fmtFileSize } from './files.js';
|
|
6
6
|
const h = webjsx.createElement;
|
|
7
7
|
|
|
@@ -154,8 +154,8 @@ export function FileViewer({ file, body, onClose, onAction } = {}) {
|
|
|
154
154
|
h('span', { class: 'ds-preview-name' }, file.name || ''),
|
|
155
155
|
h('span', { class: 'ds-preview-meta' }, meta),
|
|
156
156
|
h('span', { class: 'ds-preview-actions' },
|
|
157
|
-
onAction ? h('button', { class: 'ds-file-act', title: 'download', onclick: () => onAction('download') }, '
|
|
158
|
-
h('button', { class: 'ds-file-act', title: 'close', onclick: onClose }, '
|
|
157
|
+
onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
|
|
158
|
+
h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
|
|
159
159
|
)
|
|
160
160
|
),
|
|
161
161
|
h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
|
package/src/components/files.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// File primitives — matches upstream signatures.
|
|
2
2
|
|
|
3
3
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
4
|
-
import { Btn } from './shell.js';
|
|
4
|
+
import { Btn, Icon } from './shell.js';
|
|
5
5
|
const h = webjsx.createElement;
|
|
6
6
|
|
|
7
7
|
const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
|
|
8
|
-
const
|
|
9
|
-
dir: '
|
|
10
|
-
text: '
|
|
8
|
+
const TYPE_ICON = {
|
|
9
|
+
dir: 'file', image: 'file', video: 'file-video', audio: 'file-audio', code: 'file-code',
|
|
10
|
+
text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'file', other: 'file'
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
const TYPE_LABELS = {
|
|
@@ -24,7 +24,7 @@ const TYPE_LABELS = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
export function fileGlyph(type) {
|
|
27
|
-
return
|
|
27
|
+
return TYPE_ICON[type] || TYPE_ICON.other;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function fmtFileSize(bytes) {
|
|
@@ -36,7 +36,7 @@ export function fmtFileSize(bytes) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function FileIcon({ type = 'other' } = {}) {
|
|
39
|
-
return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, fileGlyph(type));
|
|
39
|
+
return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key } = {}) {
|
|
@@ -64,9 +64,9 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
64
64
|
h('span', { class: 'title' }, name),
|
|
65
65
|
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
|
|
66
66
|
onAction ? h('span', { class: 'ds-file-actions', onclick: (e) => e.stopPropagation(), role: 'group', 'aria-label': `actions for ${name}` },
|
|
67
|
-
h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, '
|
|
68
|
-
h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, '
|
|
69
|
-
h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, '
|
|
67
|
+
h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
|
|
68
|
+
h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
|
|
69
|
+
h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
|
|
70
70
|
) : null
|
|
71
71
|
);
|
|
72
72
|
}
|
|
@@ -141,7 +141,7 @@ export function UploadProgress({ items = [] } = {}) {
|
|
|
141
141
|
);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
export function EmptyState({ text = 'nothing here', glyph = '
|
|
144
|
+
export function EmptyState({ text = 'nothing here', glyph = Icon('circle') } = {}) {
|
|
145
145
|
return h('div', { class: 'ds-file-empty' },
|
|
146
146
|
h('span', { class: 'ds-file-empty-glyph' }, glyph),
|
|
147
147
|
h('span', { class: 'ds-file-empty-text' }, text)
|
|
@@ -151,7 +151,7 @@ export function EmptyState({ text = 'nothing here', glyph = '◌' } = {}) {
|
|
|
151
151
|
export function BreadcrumbPath({ segments = [], onNav, root = 'root' } = {}) {
|
|
152
152
|
const parts = [h('button', { key: 'root', class: 'ds-crumb-seg', onclick: () => onNav && onNav(0) }, root)];
|
|
153
153
|
segments.forEach((seg, i) => {
|
|
154
|
-
parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep' }, '
|
|
154
|
+
parts.push(h('span', { key: 'sep' + i, class: 'ds-crumb-sep', 'aria-hidden': 'true' }, Icon('chevron-right', { size: 13 })));
|
|
155
155
|
parts.push(h('button', {
|
|
156
156
|
key: 'seg' + i,
|
|
157
157
|
class: 'ds-crumb-seg' + (i === segments.length - 1 ? ' leaf' : ''),
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// a ref callback that uses the SDK's own applyDiff.
|
|
6
6
|
|
|
7
7
|
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
8
|
+
import { Icon } from '../shell.js';
|
|
8
9
|
const h = webjsx.createElement;
|
|
9
10
|
const applyDiff = webjsx.applyDiff;
|
|
10
11
|
|
|
@@ -57,7 +58,7 @@ export function makePage(setup, { initial = {} } = {}) {
|
|
|
57
58
|
try { body = render(); }
|
|
58
59
|
catch (e) {
|
|
59
60
|
body = h('div', { class: 'ds-alert ds-alert-error', role: 'alert' },
|
|
60
|
-
h('span', { class: 'ds-alert-icon' }, '
|
|
61
|
+
h('span', { class: 'ds-alert-icon' }, Icon('x')),
|
|
61
62
|
h('div', { class: 'ds-alert-content' },
|
|
62
63
|
h('div', { class: 'ds-alert-title' }, 'page render error'),
|
|
63
64
|
h('pre', { class: 'fd-pre' }, String(e && e.stack || e))));
|
|
@@ -70,7 +71,14 @@ export function makePage(setup, { initial = {} } = {}) {
|
|
|
70
71
|
elRef = el;
|
|
71
72
|
const r = setup(ctx);
|
|
72
73
|
if (typeof r === 'function') render = r;
|
|
74
|
+
// Paint immediately, then again on the next microtask. Pages whose
|
|
75
|
+
// setup only seeds state synchronously (chat, batch) get a single
|
|
76
|
+
// ref-time paint; if that paint lands before the node is fully live
|
|
77
|
+
// in the document the diff can no-op, leaving an empty page-root.
|
|
78
|
+
// The deferred second paint guarantees content regardless of attach
|
|
79
|
+
// timing. Pages that also load() async are unaffected (idempotent).
|
|
73
80
|
ctx.rerender();
|
|
81
|
+
Promise.resolve().then(() => ctx.rerender());
|
|
74
82
|
};
|
|
75
83
|
return h('div', { class: 'fd-page-root', ref });
|
|
76
84
|
};
|
|
@@ -87,14 +95,14 @@ export function loadingState(label = 'loading…') {
|
|
|
87
95
|
export function errorState(err, onRetry) {
|
|
88
96
|
const msg = String(err && err.message || err);
|
|
89
97
|
return h('div', { class: 'ds-alert ds-alert-error', role: 'alert' },
|
|
90
|
-
h('span', { class: 'ds-alert-icon' }, '
|
|
98
|
+
h('span', { class: 'ds-alert-icon' }, Icon('x')),
|
|
91
99
|
h('div', { class: 'ds-alert-content' },
|
|
92
100
|
h('div', { class: 'ds-alert-title' }, 'failed to load'),
|
|
93
101
|
h('div', { class: 'ds-alert-message' }, msg),
|
|
94
|
-
onRetry ? h('button', { class: 'btn', onclick: onRetry
|
|
102
|
+
onRetry ? h('button', { class: 'btn ds-alert-retry', onclick: onRetry }, 'retry') : null));
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
export function emptyState(text = 'nothing here yet', glyph = '
|
|
105
|
+
export function emptyState(text = 'nothing here yet', glyph = Icon('circle')) {
|
|
98
106
|
return h('div', { class: 'fd-empty', role: 'status' },
|
|
99
107
|
h('div', { class: 'fd-empty-glyph', 'aria-hidden': 'true' }, glyph),
|
|
100
108
|
h('div', { class: 'dim' }, text));
|
|
@@ -8,7 +8,7 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
8
8
|
import { makePage, api, loadingState, errorState, emptyState } from './freddie/runtime.js';
|
|
9
9
|
import { getRecentPaths, saveRecentPath, skillLabel, renderChatMessages } from './freddie/helpers.js';
|
|
10
10
|
import { Panel, Row, Table, Kpi, PageHeader, SearchInput, TextField, Select } from './content.js';
|
|
11
|
-
import { Chip, Btn } from './shell.js';
|
|
11
|
+
import { Chip, Btn, Icon } from './shell.js';
|
|
12
12
|
import { ChatMessage, ChatComposer } from './chat.js';
|
|
13
13
|
|
|
14
14
|
const h = webjsx.createElement;
|
|
@@ -29,7 +29,7 @@ const noteAlert = (note) => note ? h('div', { class: 'ds-alert ds-alert-' + note
|
|
|
29
29
|
h('span', { class: 'ds-alert-icon' }, '!'),
|
|
30
30
|
h('div', { class: 'ds-alert-content' }, note.msg)) : null;
|
|
31
31
|
// Manual refresh button for non-polling pages — parity with auto-refreshing ones.
|
|
32
|
-
const refreshBtn = (onClick, busy) => Btn({ children: busy ? 'refreshing…' : '
|
|
32
|
+
const refreshBtn = (onClick, busy) => Btn({ children: busy ? 'refreshing…' : [Icon('refresh'), ' refresh'], disabled: !!busy, onClick, 'aria-label': 'refresh' });
|
|
33
33
|
// Non-blocking refresh-error banner: keep last-good content, surface the failure.
|
|
34
34
|
const refreshError = (err) => err ? h('div', { class: 'ds-alert ds-alert-warn', role: 'status', 'aria-live': 'polite' },
|
|
35
35
|
h('span', { class: 'ds-alert-icon' }, '!'),
|
|
@@ -104,7 +104,7 @@ export const chat = makePage((ctx) => {
|
|
|
104
104
|
const reply = r.result || r.content || r.message || (r.messages && r.messages.at(-1)?.content) || JSON.stringify(r);
|
|
105
105
|
ctx.state.messages.push({ role: 'assistant', text: String(reply), time: new Date().toLocaleTimeString() });
|
|
106
106
|
} catch (e) {
|
|
107
|
-
ctx.state.messages.push({ role: 'assistant', text: '
|
|
107
|
+
ctx.state.messages.push({ role: 'assistant', text: 'Error: ' + String(e.message || e), time: new Date().toLocaleTimeString() });
|
|
108
108
|
}
|
|
109
109
|
ctx.set({ sending: false });
|
|
110
110
|
}
|
|
@@ -116,7 +116,7 @@ export const chat = makePage((ctx) => {
|
|
|
116
116
|
h('div', { class: 'chat-thread fd-chat-thread', role: 'log', 'aria-label': 'chat messages',
|
|
117
117
|
ref: stickyScroll },
|
|
118
118
|
s.messages.length ? s.messages.map((m, i) => ChatMessage({ ...m, key: i }))
|
|
119
|
-
: emptyState('send a prompt to start', '
|
|
119
|
+
: emptyState('send a prompt to start', Icon('forum')),
|
|
120
120
|
s.sending ? ChatMessage({ role: 'assistant', typing: true, key: '_typing' }) : null),
|
|
121
121
|
ChatComposer({
|
|
122
122
|
value: s.draft,
|
|
@@ -225,7 +225,7 @@ export const projects = makePage((ctx) => {
|
|
|
225
225
|
noteAlert(s.note),
|
|
226
226
|
section('projects',
|
|
227
227
|
list.length ? list.map((p, i) => Row({
|
|
228
|
-
key: i, code: p.name === activeName ? '
|
|
228
|
+
key: i, code: h('span', { class: 'ds-dot ' + (p.name === activeName ? 'ds-dot-on' : 'ds-dot-off'), 'aria-hidden': 'true' }), title: p.name, sub: p.path || '',
|
|
229
229
|
active: p.name === activeName,
|
|
230
230
|
trailing: h('span', { class: 'fd-row-actions' },
|
|
231
231
|
p.name !== activeName ? Btn({ children: 'activate', onClick: () => activate(p.name) }) : Chip({ tone: 'ok', children: 'active' }),
|
|
@@ -354,7 +354,7 @@ export const cron = makePage((ctx) => {
|
|
|
354
354
|
PageHeader({ eyebrow: 'freddie', title: 'cron', lede: list.length + ' scheduled jobs' }),
|
|
355
355
|
noteAlert(s.note),
|
|
356
356
|
section('jobs', list.length ? list.map((j, i) => Row({
|
|
357
|
-
key: i, code: j.enabled ? '
|
|
357
|
+
key: i, code: j.enabled ? Icon('play') : Icon('pause'), title: j.cron, sub: (j.prompt || '').slice(0, 80),
|
|
358
358
|
trailing: Btn({ danger: true, children: 'delete', onClick: () => del(j.id) }),
|
|
359
359
|
})) : emptyState('no cron jobs')),
|
|
360
360
|
section('new job',
|