@virtengine/openfleet 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -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/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -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 +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -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 +282 -0
- package/publish.mjs +151 -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 +3937 -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 +9281 -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 +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -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 +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -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 +74 -0
- package/ui/tabs/control.js +887 -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 +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -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,473 @@
|
|
|
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
|
+
// BackButton integration
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const tg = getTg();
|
|
144
|
+
if (!tg?.BackButton) return;
|
|
145
|
+
|
|
146
|
+
const handler = () => {
|
|
147
|
+
onClose();
|
|
148
|
+
tg.BackButton.hide();
|
|
149
|
+
tg.BackButton.offClick(handler);
|
|
150
|
+
};
|
|
151
|
+
tg.BackButton.show();
|
|
152
|
+
tg.BackButton.onClick(handler);
|
|
153
|
+
|
|
154
|
+
return () => {
|
|
155
|
+
tg.BackButton.hide();
|
|
156
|
+
tg.BackButton.offClick(handler);
|
|
157
|
+
};
|
|
158
|
+
}, [onClose]);
|
|
159
|
+
|
|
160
|
+
// Prevent body scroll while dragging
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (dragState.current.dragging) {
|
|
163
|
+
document.body.style.overflow = "hidden";
|
|
164
|
+
return () => { document.body.style.overflow = ""; };
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const handleTouchStart = useCallback((e) => {
|
|
169
|
+
const el = contentRef.current;
|
|
170
|
+
if (!el) return;
|
|
171
|
+
const rect = el.getBoundingClientRect();
|
|
172
|
+
const touchY = e.touches[0].clientY;
|
|
173
|
+
// Only start drag if touch is within top 60px of the modal content
|
|
174
|
+
if (touchY - rect.top > 60) return;
|
|
175
|
+
dragState.current = { startY: touchY, startRect: rect.top, dragging: true };
|
|
176
|
+
// Disable transition during active drag
|
|
177
|
+
el.style.transition = "none";
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
const handleTouchMove = useCallback((e) => {
|
|
181
|
+
if (!dragState.current.dragging) return;
|
|
182
|
+
const deltaY = e.touches[0].clientY - dragState.current.startY;
|
|
183
|
+
if (deltaY < 0) {
|
|
184
|
+
setDragY(0);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Diminishing returns past 100px
|
|
188
|
+
const translated = deltaY <= 100 ? deltaY : 100 + (deltaY - 100) * 0.3;
|
|
189
|
+
setDragY(translated);
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const handleTouchEnd = useCallback(() => {
|
|
194
|
+
if (!dragState.current.dragging) return;
|
|
195
|
+
dragState.current.dragging = false;
|
|
196
|
+
const el = contentRef.current;
|
|
197
|
+
if (el) el.style.transition = "";
|
|
198
|
+
if (dragY > 150) {
|
|
199
|
+
getTg()?.HapticFeedback?.impactOccurred("light");
|
|
200
|
+
onClose();
|
|
201
|
+
}
|
|
202
|
+
setDragY(0);
|
|
203
|
+
}, [dragY, onClose]);
|
|
204
|
+
|
|
205
|
+
const handleTouchCancel = useCallback(() => {
|
|
206
|
+
if (!dragState.current.dragging) return;
|
|
207
|
+
dragState.current.dragging = false;
|
|
208
|
+
const el = contentRef.current;
|
|
209
|
+
if (el) el.style.transition = "";
|
|
210
|
+
setDragY(0);
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
if (!open) return null;
|
|
214
|
+
|
|
215
|
+
const dragStyle = dragY > 0
|
|
216
|
+
? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
|
|
217
|
+
: "";
|
|
218
|
+
|
|
219
|
+
return html`
|
|
220
|
+
<div
|
|
221
|
+
class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
|
|
222
|
+
onClick=${(e) => {
|
|
223
|
+
if (e.target === e.currentTarget) onClose();
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<div
|
|
227
|
+
ref=${contentRef}
|
|
228
|
+
class="modal-content ${visible ? "modal-content-visible" : ""} ${dragY > 0 ? "modal-dragging" : ""}"
|
|
229
|
+
style=${dragStyle}
|
|
230
|
+
onClick=${(e) => e.stopPropagation()}
|
|
231
|
+
onTouchStart=${handleTouchStart}
|
|
232
|
+
onTouchMove=${handleTouchMove}
|
|
233
|
+
onTouchEnd=${handleTouchEnd}
|
|
234
|
+
onTouchCancel=${handleTouchCancel}
|
|
235
|
+
>
|
|
236
|
+
<div class="modal-handle"></div>
|
|
237
|
+
${title ? html`<div class="modal-title">${title}</div>` : null}
|
|
238
|
+
${children}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ═══════════════════════════════════════════════
|
|
245
|
+
* ConfirmDialog
|
|
246
|
+
* ═══════════════════════════════════════════════ */
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Confirmation dialog — tries Telegram native showConfirm first, falls back to styled modal.
|
|
250
|
+
* @param {{title?: string, message: string, confirmText?: string, cancelText?: string, onConfirm: () => void, onCancel: () => void, destructive?: boolean}} props
|
|
251
|
+
*/
|
|
252
|
+
export function ConfirmDialog({
|
|
253
|
+
title = "Confirm",
|
|
254
|
+
message,
|
|
255
|
+
confirmText = "Confirm",
|
|
256
|
+
cancelText = "Cancel",
|
|
257
|
+
onConfirm,
|
|
258
|
+
onCancel,
|
|
259
|
+
destructive = false,
|
|
260
|
+
}) {
|
|
261
|
+
const [tried, setTried] = useState(false);
|
|
262
|
+
|
|
263
|
+
// Try Telegram native confirm first
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
const tg = getTg();
|
|
266
|
+
if (tg?.showConfirm && !tried) {
|
|
267
|
+
setTried(true);
|
|
268
|
+
tg.showConfirm(message, (ok) => {
|
|
269
|
+
if (ok) onConfirm();
|
|
270
|
+
else onCancel();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}, [message, onConfirm, onCancel, tried]);
|
|
274
|
+
|
|
275
|
+
// If Telegram native is available, render nothing (native dialog handles it)
|
|
276
|
+
if (getTg()?.showConfirm) return null;
|
|
277
|
+
|
|
278
|
+
const confirmBtnStyle = destructive
|
|
279
|
+
? "background: var(--destructive); color: #fff;"
|
|
280
|
+
: "";
|
|
281
|
+
|
|
282
|
+
return html`
|
|
283
|
+
<div class="modal-overlay modal-overlay-visible" onClick=${onCancel}>
|
|
284
|
+
<div
|
|
285
|
+
class="confirm-dialog"
|
|
286
|
+
onClick=${(e) => e.stopPropagation()}
|
|
287
|
+
>
|
|
288
|
+
<div class="confirm-dialog-title">${title}</div>
|
|
289
|
+
<div class="confirm-dialog-message">${message}</div>
|
|
290
|
+
<div class="confirm-dialog-actions">
|
|
291
|
+
<button class="btn btn-secondary" onClick=${onCancel}>
|
|
292
|
+
${cancelText}
|
|
293
|
+
</button>
|
|
294
|
+
<button
|
|
295
|
+
class="btn btn-primary ${destructive ? "btn-destructive" : ""}"
|
|
296
|
+
style=${confirmBtnStyle}
|
|
297
|
+
onClick=${onConfirm}
|
|
298
|
+
>
|
|
299
|
+
${confirmText}
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ═══════════════════════════════════════════════
|
|
308
|
+
* confirmAction (Promise-based utility)
|
|
309
|
+
* ═══════════════════════════════════════════════ */
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Quick inline confirmation — returns a Promise<boolean>.
|
|
313
|
+
* Uses Telegram native confirm if available, otherwise browser confirm().
|
|
314
|
+
* @param {string} message
|
|
315
|
+
* @returns {Promise<boolean>}
|
|
316
|
+
*/
|
|
317
|
+
export function confirmAction(message) {
|
|
318
|
+
const tg = getTg();
|
|
319
|
+
if (tg?.showConfirm) {
|
|
320
|
+
return new Promise((resolve) => tg.showConfirm(message, resolve));
|
|
321
|
+
}
|
|
322
|
+
return Promise.resolve(window.confirm(message));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ═══════════════════════════════════════════════
|
|
326
|
+
* Toast / ToastContainer
|
|
327
|
+
* ═══════════════════════════════════════════════ */
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Renders all active toasts from the toasts signal.
|
|
331
|
+
* Each toast auto-dismisses (handled by showToast in state.js).
|
|
332
|
+
*/
|
|
333
|
+
export function ToastContainer() {
|
|
334
|
+
const items = toasts.value;
|
|
335
|
+
if (!items.length) return null;
|
|
336
|
+
|
|
337
|
+
const visible = items.filter(shouldShowToast);
|
|
338
|
+
if (!visible.length) return null;
|
|
339
|
+
|
|
340
|
+
return html`
|
|
341
|
+
<div class="toast-container">
|
|
342
|
+
${visible.map(
|
|
343
|
+
(t) => html`
|
|
344
|
+
<div key=${t.id} class="toast toast-${t.type}">
|
|
345
|
+
<span class="toast-message">${t.message}</span>
|
|
346
|
+
<button
|
|
347
|
+
class="toast-close"
|
|
348
|
+
onClick=${() => {
|
|
349
|
+
toasts.value = toasts.value.filter((x) => x.id !== t.id);
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
×
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
`,
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ═══════════════════════════════════════════════
|
|
362
|
+
* EmptyState
|
|
363
|
+
* ═══════════════════════════════════════════════ */
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Empty state display.
|
|
367
|
+
* @param {{icon?: string, title?: string, message?: string, description?: string, action?: {label: string, onClick: () => void}}} props
|
|
368
|
+
*/
|
|
369
|
+
export function EmptyState({ icon, title, message, description, action }) {
|
|
370
|
+
const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
|
|
371
|
+
const displayIcon = iconSvg ? html`<div class="empty-state-icon">${iconSvg}</div>`
|
|
372
|
+
: icon ? html`<div class="empty-state-icon">${icon}</div>`
|
|
373
|
+
: null;
|
|
374
|
+
const displayTitle = title || message || null;
|
|
375
|
+
return html`
|
|
376
|
+
<div class="empty-state">
|
|
377
|
+
${displayIcon}
|
|
378
|
+
${displayTitle ? html`<div class="empty-state-title">${displayTitle}</div>` : null}
|
|
379
|
+
${description
|
|
380
|
+
? html`<div class="empty-state-description">${description}</div>`
|
|
381
|
+
: null}
|
|
382
|
+
${action
|
|
383
|
+
? html`<button class="btn btn-primary btn-sm" onClick=${action.onClick}>
|
|
384
|
+
${action.label}
|
|
385
|
+
</button>`
|
|
386
|
+
: null}
|
|
387
|
+
</div>
|
|
388
|
+
`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/* ═══════════════════════════════════════════════
|
|
392
|
+
* Divider
|
|
393
|
+
* ═══════════════════════════════════════════════ */
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Section divider with optional centered label.
|
|
397
|
+
* @param {{label?: string}} props
|
|
398
|
+
*/
|
|
399
|
+
export function Divider({ label }) {
|
|
400
|
+
if (!label) return html`<div class="divider"></div>`;
|
|
401
|
+
return html`
|
|
402
|
+
<div class="divider divider-label">
|
|
403
|
+
<span>${label}</span>
|
|
404
|
+
</div>
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* ═══════════════════════════════════════════════
|
|
409
|
+
* Avatar
|
|
410
|
+
* ═══════════════════════════════════════════════ */
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Circle avatar with initials fallback.
|
|
414
|
+
* @param {{name?: string, size?: number, src?: string}} props
|
|
415
|
+
*/
|
|
416
|
+
export function Avatar({ name = "", size = 36, src }) {
|
|
417
|
+
const initials = name
|
|
418
|
+
.split(/\s+/)
|
|
419
|
+
.slice(0, 2)
|
|
420
|
+
.map((w) => w.charAt(0).toUpperCase())
|
|
421
|
+
.join("");
|
|
422
|
+
|
|
423
|
+
const style = `width:${size}px;height:${size}px;border-radius:50%;overflow:hidden;
|
|
424
|
+
display:flex;align-items:center;justify-content:center;
|
|
425
|
+
background:var(--accent,#5b6eae);color:var(--accent-text,#fff);
|
|
426
|
+
font-size:${Math.round(size * 0.4)}px;font-weight:600;flex-shrink:0`;
|
|
427
|
+
|
|
428
|
+
if (src) {
|
|
429
|
+
return html`
|
|
430
|
+
<div style=${style}>
|
|
431
|
+
<img
|
|
432
|
+
src=${src}
|
|
433
|
+
alt=${name}
|
|
434
|
+
style="width:100%;height:100%;object-fit:cover"
|
|
435
|
+
onError=${(e) => {
|
|
436
|
+
e.target.style.display = "none";
|
|
437
|
+
}}
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
440
|
+
`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return html`<div style=${style}>${initials || "?"}</div>`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* ═══════════════════════════════════════════════
|
|
447
|
+
* ListItem
|
|
448
|
+
* ═══════════════════════════════════════════════ */
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Generic list item for settings-style lists.
|
|
452
|
+
* @param {{title: string, subtitle?: string, trailing?: any, onClick?: () => void, icon?: string}} props
|
|
453
|
+
*/
|
|
454
|
+
export function ListItem({ title, subtitle, trailing, onClick, icon }) {
|
|
455
|
+
const iconSvg = icon && ICONS[icon] ? ICONS[icon] : null;
|
|
456
|
+
return html`
|
|
457
|
+
<div
|
|
458
|
+
class=${classNames("list-item", { "list-item-clickable": !!onClick })}
|
|
459
|
+
onClick=${onClick}
|
|
460
|
+
>
|
|
461
|
+
${iconSvg ? html`<div class="list-item-icon">${iconSvg}</div>` : null}
|
|
462
|
+
<div class="list-item-body">
|
|
463
|
+
<div class="list-item-title">${title}</div>
|
|
464
|
+
${subtitle
|
|
465
|
+
? html`<div class="list-item-subtitle">${subtitle}</div>`
|
|
466
|
+
: null}
|
|
467
|
+
</div>
|
|
468
|
+
${trailing != null
|
|
469
|
+
? html`<div class="list-item-trailing">${trailing}</div>`
|
|
470
|
+
: null}
|
|
471
|
+
</div>
|
|
472
|
+
`;
|
|
473
|
+
}
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<title>VirtEngine Control Center</title>
|
|
8
|
+
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
|
9
|
+
<link rel="preconnect" href="https://esm.sh" crossorigin />
|
|
10
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Sora:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
|
13
|
+
<link rel="stylesheet" href="styles.css" />
|
|
14
|
+
<link rel="stylesheet" href="styles/kanban.css" />
|
|
15
|
+
<link rel="stylesheet" href="styles/sessions.css" />
|
|
16
|
+
<!-- Import map: ensures all modules share a single Preact instance
|
|
17
|
+
(required for @preact/signals auto-subscription to work) -->
|
|
18
|
+
<script type="importmap">
|
|
19
|
+
{
|
|
20
|
+
"imports": {
|
|
21
|
+
"preact": "https://esm.sh/preact@10.25.4",
|
|
22
|
+
"preact/hooks": "https://esm.sh/preact@10.25.4/hooks",
|
|
23
|
+
"htm": "https://esm.sh/htm@3.1.1",
|
|
24
|
+
"@preact/signals": "https://esm.sh/@preact/signals@1.3.1?deps=preact@10.25.4"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
</script>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<div id="app">
|
|
31
|
+
<div id="boot-loader" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:'Sora',sans-serif;color:#9aa3b2;background:#0b0f14;background-image:radial-gradient(circle at 20% 10%,rgba(76,201,240,.18),transparent 55%),radial-gradient(circle at 80% 0%,rgba(94,234,212,.12),transparent 45%),radial-gradient(circle at 50% 100%,rgba(59,130,246,.12),transparent 60%);overflow:hidden">
|
|
32
|
+
<style>
|
|
33
|
+
.boot-logo{position:relative;width:80px;height:80px;margin-bottom:24px}
|
|
34
|
+
.boot-ring{position:absolute;inset:0;border-radius:50%;border:2px solid rgba(99,102,241,.15);animation:boot-spin 3s linear infinite}
|
|
35
|
+
.boot-ring-inner{position:absolute;inset:8px;border-radius:50%;border:2px solid transparent;border-top-color:#6366f1;border-right-color:rgba(99,102,241,.4);animation:boot-spin 1.5s linear infinite reverse}
|
|
36
|
+
.boot-dot{position:absolute;top:50%;left:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 0 20px rgba(99,102,241,.5),0 0 40px rgba(99,102,241,.2);animation:boot-pulse 2s ease-in-out infinite}
|
|
37
|
+
.boot-title{font-size:22px;font-weight:700;letter-spacing:-.02em;background:linear-gradient(135deg,#f1f5f9,#94a3b8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin-bottom:6px;animation:boot-fadeUp .6s ease .2s both}
|
|
38
|
+
.boot-subtitle{font-size:13px;color:#64748b;animation:boot-fadeUp .6s ease .4s both}
|
|
39
|
+
.boot-progress{width:120px;height:2px;background:rgba(255,255,255,.06);border-radius:2px;margin-top:20px;overflow:hidden;animation:boot-fadeUp .6s ease .6s both}
|
|
40
|
+
.boot-progress-bar{height:100%;width:0;background:linear-gradient(90deg,#6366f1,#a78bfa);border-radius:2px;animation:boot-load 2s ease-in-out .8s forwards}
|
|
41
|
+
@keyframes boot-spin{to{transform:rotate(360deg)}}
|
|
42
|
+
@keyframes boot-pulse{0%,100%{transform:scale(1);opacity:.8}50%{transform:scale(1.2);opacity:1}}
|
|
43
|
+
@keyframes boot-fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
|
44
|
+
@keyframes boot-load{0%{width:0}60%{width:70%}100%{width:100%}}
|
|
45
|
+
</style>
|
|
46
|
+
<div class="boot-logo">
|
|
47
|
+
<div class="boot-ring"></div>
|
|
48
|
+
<div class="boot-ring-inner"></div>
|
|
49
|
+
<div class="boot-dot"></div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="boot-title">VirtEngine</div>
|
|
52
|
+
<div class="boot-subtitle" id="boot-status">Initializing Control Center…</div>
|
|
53
|
+
<div class="boot-progress"><div class="boot-progress-bar"></div></div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<script type="module" src="app.js"></script>
|
|
57
|
+
<script>
|
|
58
|
+
// Signal Telegram that the page has loaded (before ES modules finish loading)
|
|
59
|
+
try { if (window.Telegram && Telegram.WebApp) Telegram.WebApp.ready(); } catch(e) {}
|
|
60
|
+
// Fallback: if the ES module fails to load within 12s, show an error
|
|
61
|
+
setTimeout(function() {
|
|
62
|
+
var el = document.getElementById('boot-loader');
|
|
63
|
+
if (el) {
|
|
64
|
+
var s = document.getElementById('boot-status');
|
|
65
|
+
if (s) s.innerHTML = '⚠️ Failed to load app modules.<br><small style="color:#888">Check your internet connection (CDN: esm.sh) or try refreshing.</small>';
|
|
66
|
+
}
|
|
67
|
+
}, 12000);
|
|
68
|
+
</script>
|
|
69
|
+
</body>
|
|
70
|
+
</html>
|