bosun 0.26.3
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/.env.example +918 -0
- package/LICENSE +190 -0
- package/README.md +98 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/bosun.config.example.json +115 -0
- package/bosun.schema.json +465 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +1028 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/compat.mjs +286 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1724 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12237 -0
- package/package.json +209 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +290 -0
- package/publish.mjs +241 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3946 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9683 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +357 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +525 -0
- package/ui/demo.html +640 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +2032 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +810 -0
- package/ui/styles/sessions.css +841 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +75 -0
- package/ui/tabs/control.js +892 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1509 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4084 -0
- package/update-check.mjs +471 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* VirtEngine Control Center – Shared UI Components
|
|
3
|
+
* Card, Badge, StatCard, Modal, Toast, EmptyState, etc.
|
|
4
|
+
* ────────────────────────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
import { h } from "preact";
|
|
7
|
+
import {
|
|
8
|
+
useState,
|
|
9
|
+
useEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useCallback,
|
|
12
|
+
} from "preact/hooks";
|
|
13
|
+
import htm from "htm";
|
|
14
|
+
|
|
15
|
+
const html = htm.bind(h);
|
|
16
|
+
|
|
17
|
+
import { ICONS } from "../modules/icons.js";
|
|
18
|
+
import { toasts, showToast, shouldShowToast } from "../modules/state.js";
|
|
19
|
+
import {
|
|
20
|
+
haptic,
|
|
21
|
+
showBackButton,
|
|
22
|
+
hideBackButton,
|
|
23
|
+
getTg,
|
|
24
|
+
} from "../modules/telegram.js";
|
|
25
|
+
import { classNames } from "../modules/utils.js";
|
|
26
|
+
|
|
27
|
+
/* ═══════════════════════════════════════════════
|
|
28
|
+
* Card
|
|
29
|
+
* ═══════════════════════════════════════════════ */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Card container with optional title / subtitle.
|
|
33
|
+
* @param {{title?: string, subtitle?: string, children?: any, className?: string, onClick?: () => void}} props
|
|
34
|
+
*/
|
|
35
|
+
export function Card({ title, subtitle, children, className = "", onClick }) {
|
|
36
|
+
return html`
|
|
37
|
+
<div class="card ${className}" onClick=${onClick}>
|
|
38
|
+
${title ? html`<div class="card-title">${title}</div>` : null}
|
|
39
|
+
${subtitle ? html`<div class="card-subtitle">${subtitle}</div>` : null}
|
|
40
|
+
${children}
|
|
41
|
+
</div>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ═══════════════════════════════════════════════
|
|
46
|
+
* Badge
|
|
47
|
+
* ═══════════════════════════════════════════════ */
|
|
48
|
+
|
|
49
|
+
const BADGE_STATUS_MAP = new Set([
|
|
50
|
+
"draft",
|
|
51
|
+
"todo",
|
|
52
|
+
"inprogress",
|
|
53
|
+
"inreview",
|
|
54
|
+
"done",
|
|
55
|
+
"error",
|
|
56
|
+
"cancelled",
|
|
57
|
+
"critical",
|
|
58
|
+
"high",
|
|
59
|
+
"medium",
|
|
60
|
+
"low",
|
|
61
|
+
"log",
|
|
62
|
+
"info",
|
|
63
|
+
"warning",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Status badge pill.
|
|
68
|
+
* @param {{status?: string, text?: string, className?: string}} props
|
|
69
|
+
*/
|
|
70
|
+
export function Badge({ status, text, className = "" }) {
|
|
71
|
+
const label = text || status || "";
|
|
72
|
+
const normalized = (status || "").toLowerCase().replace(/\s+/g, "");
|
|
73
|
+
const statusClass = BADGE_STATUS_MAP.has(normalized)
|
|
74
|
+
? `badge-${normalized}`
|
|
75
|
+
: "";
|
|
76
|
+
return html`<span class="badge ${statusClass} ${className}">${label}</span>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ═══════════════════════════════════════════════
|
|
80
|
+
* StatCard
|
|
81
|
+
* ═══════════════════════════════════════════════ */
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stat display card with large value and small label.
|
|
85
|
+
* @param {{value: any, label: string, trend?: 'up'|'down', color?: string}} props
|
|
86
|
+
*/
|
|
87
|
+
export function StatCard({ value, label, trend, color }) {
|
|
88
|
+
const valueStyle = color ? `color: ${color}` : "";
|
|
89
|
+
const trendIcon =
|
|
90
|
+
trend === "up"
|
|
91
|
+
? html`<span class="stat-trend stat-trend-up">↑</span>`
|
|
92
|
+
: trend === "down"
|
|
93
|
+
? html`<span class="stat-trend stat-trend-down">↓</span>`
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
return html`
|
|
97
|
+
<div class="stat-card">
|
|
98
|
+
<div class="stat-value" style=${valueStyle}>
|
|
99
|
+
${value ?? "—"}${trendIcon}
|
|
100
|
+
</div>
|
|
101
|
+
<div class="stat-label">${label}</div>
|
|
102
|
+
</div>
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ═══════════════════════════════════════════════
|
|
107
|
+
* SkeletonCard
|
|
108
|
+
* ═══════════════════════════════════════════════ */
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Animated loading placeholder.
|
|
112
|
+
* @param {{height?: string, className?: string}} props
|
|
113
|
+
*/
|
|
114
|
+
export function SkeletonCard({ height = "80px", className = "" }) {
|
|
115
|
+
return html`
|
|
116
|
+
<div
|
|
117
|
+
class="skeleton skeleton-card ${className}"
|
|
118
|
+
style="height: ${height}"
|
|
119
|
+
></div>
|
|
120
|
+
`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ═══════════════════════════════════════════════
|
|
124
|
+
* Modal (Bottom Sheet)
|
|
125
|
+
* ═══════════════════════════════════════════════ */
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bottom-sheet modal with drag handle, title, swipe-to-dismiss, and TG BackButton integration.
|
|
129
|
+
* @param {{title?: string, open?: boolean, onClose: () => void, children?: any}} props
|
|
130
|
+
*/
|
|
131
|
+
export function Modal({ title, open = true, onClose, children }) {
|
|
132
|
+
const [visible, setVisible] = useState(false);
|
|
133
|
+
const contentRef = useRef(null);
|
|
134
|
+
const dragState = useRef({ startY: 0, startRect: 0, dragging: false });
|
|
135
|
+
const [dragY, setDragY] = useState(0);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
requestAnimationFrame(() => setVisible(open));
|
|
139
|
+
}, [open]);
|
|
140
|
+
|
|
141
|
+
// Escape key to close (desktop support)
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!open) return;
|
|
144
|
+
const handler = (e) => { if (e.key === 'Escape' && onClose) onClose(); };
|
|
145
|
+
document.addEventListener('keydown', handler);
|
|
146
|
+
return () => document.removeEventListener('keydown', handler);
|
|
147
|
+
}, [open, onClose]);
|
|
148
|
+
|
|
149
|
+
// BackButton integration
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const tg = getTg();
|
|
152
|
+
if (!tg?.BackButton) return;
|
|
153
|
+
|
|
154
|
+
const handler = () => {
|
|
155
|
+
onClose();
|
|
156
|
+
tg.BackButton.hide();
|
|
157
|
+
tg.BackButton.offClick(handler);
|
|
158
|
+
};
|
|
159
|
+
tg.BackButton.show();
|
|
160
|
+
tg.BackButton.onClick(handler);
|
|
161
|
+
|
|
162
|
+
return () => {
|
|
163
|
+
tg.BackButton.hide();
|
|
164
|
+
tg.BackButton.offClick(handler);
|
|
165
|
+
};
|
|
166
|
+
}, [onClose]);
|
|
167
|
+
|
|
168
|
+
// Prevent body scroll while dragging
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (dragState.current.dragging) {
|
|
171
|
+
document.body.style.overflow = "hidden";
|
|
172
|
+
return () => { document.body.style.overflow = ""; };
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const handleTouchStart = useCallback((e) => {
|
|
177
|
+
const el = contentRef.current;
|
|
178
|
+
if (!el) return;
|
|
179
|
+
const rect = el.getBoundingClientRect();
|
|
180
|
+
const touchY = e.touches[0].clientY;
|
|
181
|
+
// Only start drag if touch is within top 60px of the modal content
|
|
182
|
+
if (touchY - rect.top > 60) return;
|
|
183
|
+
dragState.current = { startY: touchY, startRect: rect.top, dragging: true };
|
|
184
|
+
// Disable transition during active drag
|
|
185
|
+
el.style.transition = "none";
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
const handleTouchMove = useCallback((e) => {
|
|
189
|
+
if (!dragState.current.dragging) return;
|
|
190
|
+
const deltaY = e.touches[0].clientY - dragState.current.startY;
|
|
191
|
+
if (deltaY < 0) {
|
|
192
|
+
setDragY(0);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Diminishing returns past 100px
|
|
196
|
+
const translated = deltaY <= 100 ? deltaY : 100 + (deltaY - 100) * 0.3;
|
|
197
|
+
setDragY(translated);
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
const handleTouchEnd = useCallback(() => {
|
|
202
|
+
if (!dragState.current.dragging) return;
|
|
203
|
+
dragState.current.dragging = false;
|
|
204
|
+
const el = contentRef.current;
|
|
205
|
+
if (el) el.style.transition = "";
|
|
206
|
+
if (dragY > 150) {
|
|
207
|
+
getTg()?.HapticFeedback?.impactOccurred("light");
|
|
208
|
+
onClose();
|
|
209
|
+
}
|
|
210
|
+
setDragY(0);
|
|
211
|
+
}, [dragY, onClose]);
|
|
212
|
+
|
|
213
|
+
const handleTouchCancel = useCallback(() => {
|
|
214
|
+
if (!dragState.current.dragging) return;
|
|
215
|
+
dragState.current.dragging = false;
|
|
216
|
+
const el = contentRef.current;
|
|
217
|
+
if (el) el.style.transition = "";
|
|
218
|
+
setDragY(0);
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
if (!open) return null;
|
|
222
|
+
|
|
223
|
+
const dragStyle = dragY > 0
|
|
224
|
+
? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
|
|
225
|
+
: "";
|
|
226
|
+
|
|
227
|
+
return html`
|
|
228
|
+
<div
|
|
229
|
+
class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
|
|
230
|
+
onClick=${(e) => {
|
|
231
|
+
if (e.target === e.currentTarget) onClose();
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<div
|
|
235
|
+
ref=${contentRef}
|
|
236
|
+
class="modal-content ${visible ? "modal-content-visible" : ""} ${dragY > 0 ? "modal-dragging" : ""}"
|
|
237
|
+
style=${dragStyle}
|
|
238
|
+
onClick=${(e) => e.stopPropagation()}
|
|
239
|
+
onTouchStart=${handleTouchStart}
|
|
240
|
+
onTouchMove=${handleTouchMove}
|
|
241
|
+
onTouchEnd=${handleTouchEnd}
|
|
242
|
+
onTouchCancel=${handleTouchCancel}
|
|
243
|
+
>
|
|
244
|
+
<div class="modal-header">
|
|
245
|
+
<div class="modal-handle"></div>
|
|
246
|
+
${title ? html`<div class="modal-title">${title}</div>` : null}
|
|
247
|
+
<button class="modal-close-btn" onClick=${onClose} aria-label="Close">
|
|
248
|
+
${ICONS.close}
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
${children}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ═══════════════════════════════════════════════
|
|
258
|
+
* ConfirmDialog
|
|
259
|
+
* ═══════════════════════════════════════════════ */
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Confirmation dialog — tries Telegram native showConfirm first, falls back to styled modal.
|
|
263
|
+
* @param {{title?: string, message: string, confirmText?: string, cancelText?: string, onConfirm: () => void, onCancel: () => void, destructive?: boolean}} props
|
|
264
|
+
*/
|
|
265
|
+
export function ConfirmDialog({
|
|
266
|
+
title = "Confirm",
|
|
267
|
+
message,
|
|
268
|
+
confirmText = "Confirm",
|
|
269
|
+
cancelText = "Cancel",
|
|
270
|
+
onConfirm,
|
|
271
|
+
onCancel,
|
|
272
|
+
destructive = false,
|
|
273
|
+
}) {
|
|
274
|
+
const [tried, setTried] = useState(false);
|
|
275
|
+
|
|
276
|
+
// Escape key to cancel (desktop support)
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
const handler = (e) => { if (e.key === 'Escape' && onCancel) onCancel(); };
|
|
279
|
+
document.addEventListener('keydown', handler);
|
|
280
|
+
return () => document.removeEventListener('keydown', handler);
|
|
281
|
+
}, [onCancel]);
|
|
282
|
+
|
|
283
|
+
// Try Telegram native confirm first
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
const tg = getTg();
|
|
286
|
+
if (tg?.showConfirm && !tried) {
|
|
287
|
+
setTried(true);
|
|
288
|
+
tg.showConfirm(message, (ok) => {
|
|
289
|
+
if (ok) onConfirm();
|
|
290
|
+
else onCancel();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}, [message, onConfirm, onCancel, tried]);
|
|
294
|
+
|
|
295
|
+
// If Telegram native is available, render nothing (native dialog handles it)
|
|
296
|
+
if (getTg()?.showConfirm) return null;
|
|
297
|
+
|
|
298
|
+
const confirmBtnStyle = destructive
|
|
299
|
+
? "background: var(--destructive); color: #fff;"
|
|
300
|
+
: "";
|
|
301
|
+
|
|
302
|
+
return html`
|
|
303
|
+
<div class="modal-overlay modal-overlay-visible" onClick=${onCancel}>
|
|
304
|
+
<div
|
|
305
|
+
class="confirm-dialog"
|
|
306
|
+
onClick=${(e) => e.stopPropagation()}
|
|
307
|
+
>
|
|
308
|
+
<div class="confirm-dialog-title">${title}</div>
|
|
309
|
+
<div class="confirm-dialog-message">${message}</div>
|
|
310
|
+
<div class="confirm-dialog-actions">
|
|
311
|
+
<button class="btn btn-secondary" onClick=${onCancel}>
|
|
312
|
+
${cancelText}
|
|
313
|
+
</button>
|
|
314
|
+
<button
|
|
315
|
+
class="btn btn-primary ${destructive ? "btn-destructive" : ""}"
|
|
316
|
+
style=${confirmBtnStyle}
|
|
317
|
+
onClick=${onConfirm}
|
|
318
|
+
>
|
|
319
|
+
${confirmText}
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ═══════════════════════════════════════════════
|
|
328
|
+
* confirmAction (Promise-based utility)
|
|
329
|
+
* ═══════════════════════════════════════════════ */
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Quick inline confirmation — returns a Promise<boolean>.
|
|
333
|
+
* Uses Telegram native confirm if available, otherwise browser confirm().
|
|
334
|
+
* @param {string} message
|
|
335
|
+
* @returns {Promise<boolean>}
|
|
336
|
+
*/
|
|
337
|
+
export function confirmAction(message) {
|
|
338
|
+
const tg = getTg();
|
|
339
|
+
if (tg?.showConfirm) {
|
|
340
|
+
return new Promise((resolve) => tg.showConfirm(message, resolve));
|
|
341
|
+
}
|
|
342
|
+
return Promise.resolve(window.confirm(message));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ═══════════════════════════════════════════════
|
|
346
|
+
* Spinner
|
|
347
|
+
* ═══════════════════════════════════════════════ */
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Inline SVG spinner for loading indicators.
|
|
351
|
+
* @param {{size?: number, color?: string}} props
|
|
352
|
+
*/
|
|
353
|
+
export function Spinner({ size = 16, color = "currentColor" }) {
|
|
354
|
+
return html`<svg class="spinner" width=${size} height=${size} viewBox="0 0 24 24" fill="none" stroke=${color} stroke-width="2.5" stroke-linecap="round">
|
|
355
|
+
<circle cx="12" cy="12" r="10" opacity="0.25" />
|
|
356
|
+
<path d="M12 2a10 10 0 0 1 10 10" />
|
|
357
|
+
</svg>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* ═══════════════════════════════════════════════
|
|
361
|
+
* LoadingButton
|
|
362
|
+
* ═══════════════════════════════════════════════ */
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Button that shows a spinner when loading.
|
|
366
|
+
* @param {{loading?: boolean, onClick?: () => void, children?: any, class?: string, disabled?: boolean}} props
|
|
367
|
+
*/
|
|
368
|
+
export function LoadingButton({ loading = false, onClick, children, class: cls = "", disabled = false, ...rest }) {
|
|
369
|
+
return html`<button
|
|
370
|
+
class=${`btn ${cls} ${loading ? "btn-loading" : ""}`}
|
|
371
|
+
onClick=${!loading && !disabled ? onClick : undefined}
|
|
372
|
+
disabled=${loading || disabled}
|
|
373
|
+
...${rest}
|
|
374
|
+
>${loading ? html`<${Spinner} size=${14} /> ` : ""}${children}</button>`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* ═══════════════════════════════════════════════
|
|
378
|
+
* Toast / ToastContainer
|
|
379
|
+
* ═══════════════════════════════════════════════ */
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Renders all active toasts from the toasts signal.
|
|
383
|
+
* Each toast auto-dismisses (handled by showToast in state.js).
|
|
384
|
+
*/
|
|
385
|
+
export function ToastContainer() {
|
|
386
|
+
const items = toasts.value;
|
|
387
|
+
if (!items.length) return null;
|
|
388
|
+
|
|
389
|
+
const visible = items.filter(shouldShowToast);
|
|
390
|
+
if (!visible.length) return null;
|
|
391
|
+
|
|
392
|
+
return html`
|
|
393
|
+
<div class="toast-container">
|
|
394
|
+
${visible.map(
|
|
395
|
+
(t) => html`
|
|
396
|
+
<div key=${t.id} class="toast toast-${t.type}">
|
|
397
|
+
<span class="toast-message">${t.message}</span>
|
|
398
|
+
<button
|
|
399
|
+
class="toast-close"
|
|
400
|
+
onClick=${() => {
|
|
401
|
+
toasts.value = toasts.value.filter((x) => x.id !== t.id);
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
×
|
|
405
|
+
</button>
|
|
406
|
+
</div>
|
|
407
|
+
`,
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* ═══════════════════════════════════════════════
|
|
414
|
+
* EmptyState
|
|
415
|
+
* ═══════════════════════════════════════════════ */
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Empty state display.
|
|
419
|
+
* @param {{icon?: string, title?: string, message?: string, description?: string, action?: {label: string, onClick: () => void}}} props
|
|
420
|
+
*/
|
|
421
|
+
export function EmptyState({ icon, title, message, description, action }) {
|
|
422
|
+
const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
|
|
423
|
+
const displayIcon = iconSvg ? html`<div class="empty-state-icon">${iconSvg}</div>`
|
|
424
|
+
: icon ? html`<div class="empty-state-icon">${icon}</div>`
|
|
425
|
+
: null;
|
|
426
|
+
const displayTitle = title || message || null;
|
|
427
|
+
return html`
|
|
428
|
+
<div class="empty-state">
|
|
429
|
+
${displayIcon}
|
|
430
|
+
${displayTitle ? html`<div class="empty-state-title">${displayTitle}</div>` : null}
|
|
431
|
+
${description
|
|
432
|
+
? html`<div class="empty-state-description">${description}</div>`
|
|
433
|
+
: null}
|
|
434
|
+
${action
|
|
435
|
+
? html`<button class="btn btn-primary btn-sm" onClick=${action.onClick}>
|
|
436
|
+
${action.label}
|
|
437
|
+
</button>`
|
|
438
|
+
: null}
|
|
439
|
+
</div>
|
|
440
|
+
`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ═══════════════════════════════════════════════
|
|
444
|
+
* Divider
|
|
445
|
+
* ═══════════════════════════════════════════════ */
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Section divider with optional centered label.
|
|
449
|
+
* @param {{label?: string}} props
|
|
450
|
+
*/
|
|
451
|
+
export function Divider({ label }) {
|
|
452
|
+
if (!label) return html`<div class="divider"></div>`;
|
|
453
|
+
return html`
|
|
454
|
+
<div class="divider divider-label">
|
|
455
|
+
<span>${label}</span>
|
|
456
|
+
</div>
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/* ═══════════════════════════════════════════════
|
|
461
|
+
* Avatar
|
|
462
|
+
* ═══════════════════════════════════════════════ */
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Circle avatar with initials fallback.
|
|
466
|
+
* @param {{name?: string, size?: number, src?: string}} props
|
|
467
|
+
*/
|
|
468
|
+
export function Avatar({ name = "", size = 36, src }) {
|
|
469
|
+
const initials = name
|
|
470
|
+
.split(/\s+/)
|
|
471
|
+
.slice(0, 2)
|
|
472
|
+
.map((w) => w.charAt(0).toUpperCase())
|
|
473
|
+
.join("");
|
|
474
|
+
|
|
475
|
+
const style = `width:${size}px;height:${size}px;border-radius:50%;overflow:hidden;
|
|
476
|
+
display:flex;align-items:center;justify-content:center;
|
|
477
|
+
background:var(--accent,#5b6eae);color:var(--accent-text,#fff);
|
|
478
|
+
font-size:${Math.round(size * 0.4)}px;font-weight:600;flex-shrink:0`;
|
|
479
|
+
|
|
480
|
+
if (src) {
|
|
481
|
+
return html`
|
|
482
|
+
<div style=${style}>
|
|
483
|
+
<img
|
|
484
|
+
src=${src}
|
|
485
|
+
alt=${name}
|
|
486
|
+
style="width:100%;height:100%;object-fit:cover"
|
|
487
|
+
onError=${(e) => {
|
|
488
|
+
e.target.style.display = "none";
|
|
489
|
+
}}
|
|
490
|
+
/>
|
|
491
|
+
</div>
|
|
492
|
+
`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return html`<div style=${style}>${initials || "?"}</div>`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* ═══════════════════════════════════════════════
|
|
499
|
+
* ListItem
|
|
500
|
+
* ═══════════════════════════════════════════════ */
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Generic list item for settings-style lists.
|
|
504
|
+
* @param {{title: string, subtitle?: string, trailing?: any, onClick?: () => void, icon?: string}} props
|
|
505
|
+
*/
|
|
506
|
+
export function ListItem({ title, subtitle, trailing, onClick, icon }) {
|
|
507
|
+
const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
|
|
508
|
+
return html`
|
|
509
|
+
<div
|
|
510
|
+
class=${classNames("list-item", { "list-item-clickable": !!onClick })}
|
|
511
|
+
onClick=${onClick}
|
|
512
|
+
>
|
|
513
|
+
${iconSvg ? html`<div class="list-item-icon">${iconSvg}</div>` : null}
|
|
514
|
+
<div class="list-item-body">
|
|
515
|
+
<div class="list-item-title">${title}</div>
|
|
516
|
+
${subtitle
|
|
517
|
+
? html`<div class="list-item-subtitle">${subtitle}</div>`
|
|
518
|
+
: null}
|
|
519
|
+
</div>
|
|
520
|
+
${trailing != null
|
|
521
|
+
? html`<div class="list-item-trailing">${trailing}</div>`
|
|
522
|
+
: null}
|
|
523
|
+
</div>
|
|
524
|
+
`;
|
|
525
|
+
}
|