@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,190 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* Component: Diff Viewer — VS Code-style diff display
|
|
3
|
+
* ────────────────────────────────────────────────────────────── */
|
|
4
|
+
import { h } from "preact";
|
|
5
|
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
|
6
|
+
import htm from "htm";
|
|
7
|
+
import { apiFetch } from "../modules/api.js";
|
|
8
|
+
|
|
9
|
+
const html = htm.bind(h);
|
|
10
|
+
|
|
11
|
+
/* ─── File type icons ─── */
|
|
12
|
+
const EXT_ICONS = {
|
|
13
|
+
js: "📜", mjs: "📜", cjs: "📜",
|
|
14
|
+
ts: "🔷", tsx: "🔷",
|
|
15
|
+
json: "📋", yaml: "📋", yml: "📋", toml: "📋",
|
|
16
|
+
css: "🎨", scss: "🎨", less: "🎨",
|
|
17
|
+
html: "🌐", htm: "🌐",
|
|
18
|
+
md: "📝", txt: "📄",
|
|
19
|
+
py: "🐍", rb: "💎", go: "🔵", rs: "🦀",
|
|
20
|
+
sh: "🐚", bash: "🐚", ps1: "🐚",
|
|
21
|
+
sql: "🗃️", graphql: "🗃️",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function fileIcon(filename) {
|
|
25
|
+
const ext = (filename || "").split(".").pop().toLowerCase();
|
|
26
|
+
return EXT_ICONS[ext] || "📄";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ─── Parse unified diff into lines ─── */
|
|
30
|
+
function parseDiffLines(rawDiff) {
|
|
31
|
+
if (!rawDiff) return [];
|
|
32
|
+
return rawDiff.split("\n").map((line, i) => {
|
|
33
|
+
let type = "context";
|
|
34
|
+
if (line.startsWith("+") && !line.startsWith("+++")) type = "addition";
|
|
35
|
+
else if (line.startsWith("-") && !line.startsWith("---")) type = "deletion";
|
|
36
|
+
else if (line.startsWith("@@")) type = "hunk-header";
|
|
37
|
+
return { text: line, type, index: i };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ─── DiffFile component ─── */
|
|
42
|
+
function DiffFile({ file }) {
|
|
43
|
+
const [expanded, setExpanded] = useState(false);
|
|
44
|
+
const lines = parseDiffLines(file.patch || file.diff);
|
|
45
|
+
const additions = file.additions ?? lines.filter((l) => l.type === "addition").length;
|
|
46
|
+
const deletions = file.deletions ?? lines.filter((l) => l.type === "deletion").length;
|
|
47
|
+
|
|
48
|
+
const statusClass = file.status === "added"
|
|
49
|
+
? "added"
|
|
50
|
+
: file.status === "removed" || file.status === "deleted"
|
|
51
|
+
? "deleted"
|
|
52
|
+
: "modified";
|
|
53
|
+
|
|
54
|
+
return html`
|
|
55
|
+
<div class="diff-file-item">
|
|
56
|
+
<div
|
|
57
|
+
class="diff-file-header ${statusClass}"
|
|
58
|
+
onClick=${() => setExpanded(!expanded)}
|
|
59
|
+
>
|
|
60
|
+
<span class="diff-file-icon">${fileIcon(file.filename)}</span>
|
|
61
|
+
<span class="diff-file-name">${file.filename}</span>
|
|
62
|
+
<span class="diff-file-stats">
|
|
63
|
+
${additions > 0 && html`<span class="diff-stat-add">+${additions}</span>`}
|
|
64
|
+
${deletions > 0 && html`<span class="diff-stat-del">-${deletions}</span>`}
|
|
65
|
+
</span>
|
|
66
|
+
<span class="diff-file-toggle">${expanded ? "▾" : "▸"}</span>
|
|
67
|
+
</div>
|
|
68
|
+
${expanded && lines.length > 0 && html`
|
|
69
|
+
<div class="diff-hunk">
|
|
70
|
+
${lines.map(
|
|
71
|
+
(line) => html`
|
|
72
|
+
<div key=${line.index} class="diff-line ${line.type}">
|
|
73
|
+
<span class="diff-line-text">${line.text}</span>
|
|
74
|
+
</div>
|
|
75
|
+
`,
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
`}
|
|
79
|
+
${expanded && lines.length === 0 && html`
|
|
80
|
+
<div class="diff-hunk">
|
|
81
|
+
<div class="diff-line context">
|
|
82
|
+
<span class="diff-line-text">(no diff available)</span>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
`}
|
|
86
|
+
</div>
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ─── DiffViewer component ─── */
|
|
91
|
+
export function DiffViewer({ sessionId }) {
|
|
92
|
+
const [diffData, setDiffData] = useState(null);
|
|
93
|
+
const [loading, setLoading] = useState(false);
|
|
94
|
+
const [error, setError] = useState(null);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!sessionId) return;
|
|
98
|
+
let active = true;
|
|
99
|
+
setLoading(true);
|
|
100
|
+
setError(null);
|
|
101
|
+
|
|
102
|
+
apiFetch(`/api/sessions/${sessionId}/diff`, { _silent: true })
|
|
103
|
+
.then((res) => {
|
|
104
|
+
if (!active) return;
|
|
105
|
+
setDiffData(res?.diff || null);
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {
|
|
108
|
+
if (active) setError("unavailable");
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
if (active) setLoading(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return () => { active = false; };
|
|
115
|
+
}, [sessionId]);
|
|
116
|
+
|
|
117
|
+
const handleRetry = useCallback(() => {
|
|
118
|
+
setError(null);
|
|
119
|
+
setLoading(true);
|
|
120
|
+
apiFetch(`/api/sessions/${sessionId}/diff`, { _silent: true })
|
|
121
|
+
.then((res) => setDiffData(res?.diff || null))
|
|
122
|
+
.catch(() => setError("unavailable"))
|
|
123
|
+
.finally(() => setLoading(false));
|
|
124
|
+
}, [sessionId]);
|
|
125
|
+
|
|
126
|
+
if (!sessionId) {
|
|
127
|
+
return html`
|
|
128
|
+
<div class="diff-viewer diff-empty">
|
|
129
|
+
<div class="session-empty-icon">📝</div>
|
|
130
|
+
<div class="session-empty-text">Select a session to view diffs</div>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (loading) {
|
|
136
|
+
return html`
|
|
137
|
+
<div class="diff-viewer">
|
|
138
|
+
<div class="diff-loading">Loading diff…</div>
|
|
139
|
+
</div>
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (error) {
|
|
144
|
+
return html`
|
|
145
|
+
<div class="diff-viewer">
|
|
146
|
+
<div class="session-empty">
|
|
147
|
+
<div class="session-empty-icon">📝</div>
|
|
148
|
+
<div class="session-empty-text">Diff not available</div>
|
|
149
|
+
<button class="btn btn-primary btn-sm" onClick=${handleRetry}>
|
|
150
|
+
Retry
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const files = diffData?.files || [];
|
|
158
|
+
const totalAdditions = files.reduce(
|
|
159
|
+
(n, f) => n + (f.additions ?? 0),
|
|
160
|
+
0,
|
|
161
|
+
);
|
|
162
|
+
const totalDeletions = files.reduce(
|
|
163
|
+
(n, f) => n + (f.deletions ?? 0),
|
|
164
|
+
0,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return html`
|
|
168
|
+
<div class="diff-viewer">
|
|
169
|
+
${files.length > 0 && html`
|
|
170
|
+
<div class="diff-summary">
|
|
171
|
+
<span>${files.length} file${files.length !== 1 ? "s" : ""} changed</span>
|
|
172
|
+
${totalAdditions > 0 && html`<span class="diff-stat-add">+${totalAdditions}</span>`}
|
|
173
|
+
${totalDeletions > 0 && html`<span class="diff-stat-del">-${totalDeletions}</span>`}
|
|
174
|
+
</div>
|
|
175
|
+
`}
|
|
176
|
+
<div class="diff-file-list">
|
|
177
|
+
${files.length > 0
|
|
178
|
+
? files.map(
|
|
179
|
+
(f) => html`<${DiffFile} key=${f.filename} file=${f} />`,
|
|
180
|
+
)
|
|
181
|
+
: html`
|
|
182
|
+
<div class="session-empty">
|
|
183
|
+
<div class="session-empty-icon">✨</div>
|
|
184
|
+
<div class="session-empty-text">No changes yet</div>
|
|
185
|
+
</div>
|
|
186
|
+
`}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* VirtEngine Control Center – Form / Control Components
|
|
3
|
+
* SegmentedControl, Collapsible, PullToRefresh, SearchInput,
|
|
4
|
+
* Toggle, Stepper, SliderControl
|
|
5
|
+
* ────────────────────────────────────────────────────────────── */
|
|
6
|
+
|
|
7
|
+
import { h } from "preact";
|
|
8
|
+
import {
|
|
9
|
+
useState,
|
|
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 { haptic } from "../modules/telegram.js";
|
|
19
|
+
|
|
20
|
+
/* ═══════════════════════════════════════════════
|
|
21
|
+
* SegmentedControl
|
|
22
|
+
* ═══════════════════════════════════════════════ */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pill-shaped segmented control.
|
|
26
|
+
* @param {{options: Array<{value: string, label: string}>, value: string, onChange: (v: string) => void}} props
|
|
27
|
+
*/
|
|
28
|
+
export function SegmentedControl({ options = [], value, onChange, disabled = false }) {
|
|
29
|
+
return html`
|
|
30
|
+
<div class="segmented-control ${disabled ? "disabled" : ""}">
|
|
31
|
+
${options.map(
|
|
32
|
+
(opt) => html`
|
|
33
|
+
<button
|
|
34
|
+
key=${opt.value}
|
|
35
|
+
class="segmented-btn ${value === opt.value ? "active" : ""}"
|
|
36
|
+
disabled=${disabled}
|
|
37
|
+
onClick=${() => {
|
|
38
|
+
if (disabled) return;
|
|
39
|
+
haptic("light");
|
|
40
|
+
onChange(opt.value);
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
${opt.label}
|
|
44
|
+
</button>
|
|
45
|
+
`,
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ═══════════════════════════════════════════════
|
|
52
|
+
* Collapsible
|
|
53
|
+
* ═══════════════════════════════════════════════ */
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Expandable section with chevron rotation animation.
|
|
57
|
+
* @param {{title: string, defaultOpen?: boolean, children?: any}} props
|
|
58
|
+
*/
|
|
59
|
+
export function Collapsible({ title, defaultOpen = true, children }) {
|
|
60
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
61
|
+
|
|
62
|
+
return html`
|
|
63
|
+
<div class="collapsible">
|
|
64
|
+
<button
|
|
65
|
+
class="collapsible-header ${open ? "open" : ""}"
|
|
66
|
+
onClick=${() => {
|
|
67
|
+
haptic("light");
|
|
68
|
+
setOpen(!open);
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<span class="collapsible-title">${title}</span>
|
|
72
|
+
<span class="collapsible-chevron ${open ? "open" : ""}">
|
|
73
|
+
${ICONS.chevronDown}
|
|
74
|
+
</span>
|
|
75
|
+
</button>
|
|
76
|
+
<div class="collapsible-body ${open ? "open" : ""}">${children}</div>
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ═══════════════════════════════════════════════
|
|
82
|
+
* PullToRefresh
|
|
83
|
+
* ═══════════════════════════════════════════════ */
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Wraps content with pull-to-refresh gesture detection.
|
|
87
|
+
* Shows a spinner while refreshing.
|
|
88
|
+
* @param {{onRefresh: () => Promise<void>, children?: any}} props
|
|
89
|
+
*/
|
|
90
|
+
export function PullToRefresh({ onRefresh, children }) {
|
|
91
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
92
|
+
const [pullDistance, setPullDistance] = useState(0);
|
|
93
|
+
const containerRef = useRef(null);
|
|
94
|
+
const startYRef = useRef(0);
|
|
95
|
+
const pullingRef = useRef(false);
|
|
96
|
+
|
|
97
|
+
const THRESHOLD = 64;
|
|
98
|
+
|
|
99
|
+
const handleTouchStart = useCallback((e) => {
|
|
100
|
+
if (containerRef.current && containerRef.current.scrollTop <= 0) {
|
|
101
|
+
startYRef.current = e.touches[0].clientY;
|
|
102
|
+
pullingRef.current = true;
|
|
103
|
+
}
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const handleTouchMove = useCallback((e) => {
|
|
107
|
+
if (!pullingRef.current) return;
|
|
108
|
+
const diff = e.touches[0].clientY - startYRef.current;
|
|
109
|
+
if (diff > 0) {
|
|
110
|
+
// Apply diminishing returns to pull distance
|
|
111
|
+
setPullDistance(Math.min(diff * 0.4, THRESHOLD * 1.5));
|
|
112
|
+
}
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
const handleTouchEnd = useCallback(async () => {
|
|
116
|
+
if (!pullingRef.current) return;
|
|
117
|
+
pullingRef.current = false;
|
|
118
|
+
|
|
119
|
+
if (pullDistance >= THRESHOLD) {
|
|
120
|
+
setRefreshing(true);
|
|
121
|
+
haptic("medium");
|
|
122
|
+
try {
|
|
123
|
+
await onRefresh();
|
|
124
|
+
} finally {
|
|
125
|
+
setRefreshing(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setPullDistance(0);
|
|
129
|
+
}, [onRefresh, pullDistance]);
|
|
130
|
+
|
|
131
|
+
return html`
|
|
132
|
+
<div
|
|
133
|
+
ref=${containerRef}
|
|
134
|
+
class="pull-to-refresh-container"
|
|
135
|
+
onTouchStart=${handleTouchStart}
|
|
136
|
+
onTouchMove=${handleTouchMove}
|
|
137
|
+
onTouchEnd=${handleTouchEnd}
|
|
138
|
+
>
|
|
139
|
+
${(refreshing || pullDistance > 0) &&
|
|
140
|
+
html`
|
|
141
|
+
<div
|
|
142
|
+
class="ptr-indicator"
|
|
143
|
+
style="height: ${refreshing ? THRESHOLD : pullDistance}px;
|
|
144
|
+
display:flex;align-items:center;justify-content:center;
|
|
145
|
+
transition: ${pullingRef.current ? "none" : "height 0.2s ease"}"
|
|
146
|
+
>
|
|
147
|
+
<div
|
|
148
|
+
class="ptr-spinner-icon ${refreshing ? "spinning" : ""}"
|
|
149
|
+
style="transform: rotate(${pullDistance * 4}deg);
|
|
150
|
+
opacity: ${Math.min(1, pullDistance / THRESHOLD)}"
|
|
151
|
+
>
|
|
152
|
+
${ICONS.refresh}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
`}
|
|
156
|
+
${children}
|
|
157
|
+
</div>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ═══════════════════════════════════════════════
|
|
162
|
+
* SearchInput
|
|
163
|
+
* ═══════════════════════════════════════════════ */
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Search input with magnifying glass icon and clear button.
|
|
167
|
+
* @param {{value: string, onInput: (e: Event) => void, placeholder?: string, onClear?: () => void}} props
|
|
168
|
+
*/
|
|
169
|
+
export function SearchInput({
|
|
170
|
+
value = "",
|
|
171
|
+
onInput,
|
|
172
|
+
placeholder = "Search…",
|
|
173
|
+
onClear,
|
|
174
|
+
disabled = false,
|
|
175
|
+
inputRef,
|
|
176
|
+
}) {
|
|
177
|
+
return html`
|
|
178
|
+
<div class="search-input-wrap ${disabled ? "disabled" : ""}">
|
|
179
|
+
<span class="search-input-icon">${ICONS.search}</span>
|
|
180
|
+
<input
|
|
181
|
+
ref=${inputRef}
|
|
182
|
+
class="input search-input"
|
|
183
|
+
type="text"
|
|
184
|
+
placeholder=${placeholder}
|
|
185
|
+
value=${value}
|
|
186
|
+
onInput=${onInput}
|
|
187
|
+
disabled=${disabled}
|
|
188
|
+
/>
|
|
189
|
+
${value && !disabled
|
|
190
|
+
? html`
|
|
191
|
+
<button
|
|
192
|
+
class="search-input-clear"
|
|
193
|
+
onClick=${() => {
|
|
194
|
+
if (onClear) onClear();
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
${ICONS.close}
|
|
198
|
+
</button>
|
|
199
|
+
`
|
|
200
|
+
: null}
|
|
201
|
+
</div>
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ═══════════════════════════════════════════════
|
|
206
|
+
* Toggle
|
|
207
|
+
* ═══════════════════════════════════════════════ */
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* iOS-style toggle switch.
|
|
211
|
+
* @param {{checked: boolean, onChange: (checked: boolean) => void, label?: string}} props
|
|
212
|
+
*/
|
|
213
|
+
export function Toggle({ checked = false, onChange, label, disabled = false }) {
|
|
214
|
+
const handleClick = () => {
|
|
215
|
+
if (disabled) return;
|
|
216
|
+
haptic("light");
|
|
217
|
+
onChange(!checked);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return html`
|
|
221
|
+
<div class="toggle-wrap ${disabled ? "disabled" : ""}" onClick=${handleClick}>
|
|
222
|
+
${label ? html`<span class="toggle-label">${label}</span>` : null}
|
|
223
|
+
<div class="toggle ${checked ? "toggle-on" : ""} ${disabled ? "disabled" : ""}">
|
|
224
|
+
<div class="toggle-thumb"></div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ═══════════════════════════════════════════════
|
|
231
|
+
* Stepper
|
|
232
|
+
* ═══════════════════════════════════════════════ */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Numeric stepper with − and + buttons.
|
|
236
|
+
* @param {{value: number, min?: number, max?: number, step?: number, onChange: (v: number) => void, label?: string}} props
|
|
237
|
+
*/
|
|
238
|
+
export function Stepper({
|
|
239
|
+
value = 0,
|
|
240
|
+
min = 0,
|
|
241
|
+
max = 100,
|
|
242
|
+
step = 1,
|
|
243
|
+
onChange,
|
|
244
|
+
label,
|
|
245
|
+
disabled = false,
|
|
246
|
+
}) {
|
|
247
|
+
const decrement = () => {
|
|
248
|
+
if (disabled) return;
|
|
249
|
+
const next = Math.max(min, value - step);
|
|
250
|
+
if (next !== value) {
|
|
251
|
+
haptic("light");
|
|
252
|
+
onChange(next);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
const increment = () => {
|
|
256
|
+
if (disabled) return;
|
|
257
|
+
const next = Math.min(max, value + step);
|
|
258
|
+
if (next !== value) {
|
|
259
|
+
haptic("light");
|
|
260
|
+
onChange(next);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return html`
|
|
265
|
+
<div class="stepper-wrap ${disabled ? "disabled" : ""}">
|
|
266
|
+
${label ? html`<span class="stepper-label">${label}</span>` : null}
|
|
267
|
+
<div class="stepper ${disabled ? "disabled" : ""}">
|
|
268
|
+
<button
|
|
269
|
+
class="stepper-btn"
|
|
270
|
+
onClick=${decrement}
|
|
271
|
+
disabled=${disabled || value <= min}
|
|
272
|
+
>
|
|
273
|
+
−
|
|
274
|
+
</button>
|
|
275
|
+
<span class="stepper-value">${value}</span>
|
|
276
|
+
<button
|
|
277
|
+
class="stepper-btn"
|
|
278
|
+
onClick=${increment}
|
|
279
|
+
disabled=${disabled || value >= max}
|
|
280
|
+
>
|
|
281
|
+
+
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ═══════════════════════════════════════════════
|
|
289
|
+
* SliderControl
|
|
290
|
+
* ═══════════════════════════════════════════════ */
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Range slider with value display pill.
|
|
294
|
+
* @param {{value: number, min?: number, max?: number, step?: number, onChange: (v: number) => void, label?: string, suffix?: string}} props
|
|
295
|
+
*/
|
|
296
|
+
export function SliderControl({
|
|
297
|
+
value = 0,
|
|
298
|
+
min = 0,
|
|
299
|
+
max = 100,
|
|
300
|
+
step = 1,
|
|
301
|
+
onChange,
|
|
302
|
+
label,
|
|
303
|
+
suffix = "",
|
|
304
|
+
}) {
|
|
305
|
+
return html`
|
|
306
|
+
<div class="slider-control">
|
|
307
|
+
${label
|
|
308
|
+
? html`<div class="slider-control-header">
|
|
309
|
+
<span class="slider-control-label">${label}</span>
|
|
310
|
+
<span class="pill">${value}${suffix}</span>
|
|
311
|
+
</div>`
|
|
312
|
+
: null}
|
|
313
|
+
<div class="slider-control-row">
|
|
314
|
+
<input
|
|
315
|
+
type="range"
|
|
316
|
+
class="slider-input"
|
|
317
|
+
min=${min}
|
|
318
|
+
max=${max}
|
|
319
|
+
step=${step}
|
|
320
|
+
value=${value}
|
|
321
|
+
onInput=${(e) => onChange(Number(e.target.value))}
|
|
322
|
+
/>
|
|
323
|
+
${!label ? html`<span class="pill">${value}${suffix}</span>` : null}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
`;
|
|
327
|
+
}
|