@polderlabs/bizar-dash 3.0.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/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/dashboard/watcher.mjs
|
|
3
|
+
*
|
|
4
|
+
* chokidar-backed file watcher. Translates low-level fs events into the
|
|
5
|
+
* shape the dashboard's WebSocket layer expects.
|
|
6
|
+
*
|
|
7
|
+
* The watcher ignores initial add events (the UI doesn't need a "you just
|
|
8
|
+
* started, here are 200 files that already existed" flood on connect).
|
|
9
|
+
*/
|
|
10
|
+
import chokidar from 'chokidar';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string[]} opts.paths - files or directories to watch
|
|
15
|
+
* @param {(event: 'add'|'change'|'unlink', path: string) => void} opts.onChange
|
|
16
|
+
* @param {object} [opts.options] - extra chokidar options
|
|
17
|
+
*/
|
|
18
|
+
export function createWatcher({ paths, onChange, options = {} }) {
|
|
19
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
20
|
+
throw new Error('createWatcher requires a non-empty paths array');
|
|
21
|
+
}
|
|
22
|
+
if (typeof onChange !== 'function') {
|
|
23
|
+
throw new Error('createWatcher requires an onChange callback');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const watcher = chokidar.watch(paths, {
|
|
27
|
+
ignoreInitial: true,
|
|
28
|
+
persistent: true,
|
|
29
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
30
|
+
// Ignore: any path segment named `node_modules` (anywhere), and any
|
|
31
|
+
// file/dir whose IMMEDIATE basename starts with `.` (covers .DS_Store,
|
|
32
|
+
// .swp, .bak, etc.). We must NOT walk the whole path — the watched
|
|
33
|
+
// root `~/.config/opencode/agents` contains the segment `.config`
|
|
34
|
+
// which would otherwise match and silently disable the watch.
|
|
35
|
+
ignored: (p) => {
|
|
36
|
+
const s = String(p);
|
|
37
|
+
const parts = s.split(/[\\/]/);
|
|
38
|
+
// Walk all segments — `node_modules` is fine to match anywhere.
|
|
39
|
+
if (parts.includes('node_modules')) return true;
|
|
40
|
+
// Last segment is the basename — check that ONLY, so `.config`
|
|
41
|
+
// in parent dirs doesn't disqualify the watched root.
|
|
42
|
+
const basename = parts[parts.length - 1] || '';
|
|
43
|
+
return basename.startsWith('.');
|
|
44
|
+
},
|
|
45
|
+
...options,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const safe = (event, p) => {
|
|
49
|
+
try {
|
|
50
|
+
onChange(event, p);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// A faulty onChange must not crash the watcher
|
|
53
|
+
console.error('[dashboard watcher] onChange error:', err);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
watcher.on('add', (p) => safe('add', p));
|
|
58
|
+
watcher.on('change', (p) => safe('change', p));
|
|
59
|
+
watcher.on('unlink', (p) => safe('unlink', p));
|
|
60
|
+
watcher.on('error', (err) => {
|
|
61
|
+
console.error('[dashboard watcher] chokidar error:', err);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
/** @returns {chokidar.FSWatcher} */
|
|
66
|
+
start() {
|
|
67
|
+
return watcher;
|
|
68
|
+
},
|
|
69
|
+
async stop() {
|
|
70
|
+
try {
|
|
71
|
+
await watcher.close();
|
|
72
|
+
} catch {
|
|
73
|
+
/* ignore */
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
/** Force a synthetic broadcast — useful after a self-mutation. */
|
|
77
|
+
poke(event = 'change', path = '<synthetic>') {
|
|
78
|
+
safe(event, path);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/web/App.tsx
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// src/App.tsx — root shell. Wires data + contexts + tab routing.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { Topbar, TABS } from './components/Topbar';
|
|
5
|
+
import { ModalProvider } from './components/Modal';
|
|
6
|
+
import { ToastProvider, useToast } from './components/Toast';
|
|
7
|
+
import { SearchModal } from './components/SearchModal';
|
|
8
|
+
import { api } from './lib/api';
|
|
9
|
+
import { Ws } from './lib/ws';
|
|
10
|
+
import {
|
|
11
|
+
applyTheme,
|
|
12
|
+
applyThemeTokens,
|
|
13
|
+
type Settings,
|
|
14
|
+
type SettingsResponse,
|
|
15
|
+
type Snapshot,
|
|
16
|
+
type WsMessage,
|
|
17
|
+
type WsStatus,
|
|
18
|
+
type ProjectRecord,
|
|
19
|
+
type SearchResult,
|
|
20
|
+
} from './lib/types';
|
|
21
|
+
import { Overview } from './views/Overview';
|
|
22
|
+
import { Chat } from './views/Chat';
|
|
23
|
+
import { Agents } from './views/Agents';
|
|
24
|
+
import { Plans } from './views/Plans';
|
|
25
|
+
import { Tasks } from './views/Tasks';
|
|
26
|
+
import { Config } from './views/Config';
|
|
27
|
+
import { SettingsView } from './views/Settings';
|
|
28
|
+
import { Mods } from './views/Mods';
|
|
29
|
+
import { Schedules } from './views/Schedules';
|
|
30
|
+
import { Spinner } from './components/Spinner';
|
|
31
|
+
import './styles/main.css';
|
|
32
|
+
|
|
33
|
+
type ViewProps = {
|
|
34
|
+
snapshot: Snapshot;
|
|
35
|
+
settings: Settings;
|
|
36
|
+
activeTab: string;
|
|
37
|
+
setActiveTab: (id: string) => void;
|
|
38
|
+
refreshSnapshot: () => Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
|
|
42
|
+
overview: Overview,
|
|
43
|
+
chat: Chat,
|
|
44
|
+
agents: Agents,
|
|
45
|
+
plans: Plans,
|
|
46
|
+
tasks: Tasks,
|
|
47
|
+
config: Config,
|
|
48
|
+
settings: SettingsView,
|
|
49
|
+
mods: Mods,
|
|
50
|
+
schedules: Schedules,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const VERSION = 'v3.0.0';
|
|
54
|
+
|
|
55
|
+
export function App() {
|
|
56
|
+
return (
|
|
57
|
+
<ToastProvider>
|
|
58
|
+
<ModalProvider>
|
|
59
|
+
<Shell />
|
|
60
|
+
</ModalProvider>
|
|
61
|
+
</ToastProvider>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Shell() {
|
|
66
|
+
const toast = useToast();
|
|
67
|
+
const [activeTab, setActiveTab] = useState<string>('overview');
|
|
68
|
+
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
|
69
|
+
const [settings, setSettings] = useState<Settings | null>(null);
|
|
70
|
+
const [wsStatus, setWsStatus] = useState<WsStatus>('connecting');
|
|
71
|
+
const [bootError, setBootError] = useState<string | null>(null);
|
|
72
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
73
|
+
const wsRef = useRef<Ws | null>(null);
|
|
74
|
+
|
|
75
|
+
// Apply theme tokens
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (settings?.theme) {
|
|
78
|
+
applyTheme(settings.theme);
|
|
79
|
+
applyThemeTokens(settings.theme);
|
|
80
|
+
}
|
|
81
|
+
}, [settings?.theme]);
|
|
82
|
+
|
|
83
|
+
// System-mode listener
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (settings?.theme?.mode !== 'system') return;
|
|
86
|
+
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
|
87
|
+
const handler = () => applyTheme(settings.theme.mode);
|
|
88
|
+
mql.addEventListener('change', handler);
|
|
89
|
+
return () => mql.removeEventListener('change', handler);
|
|
90
|
+
}, [settings?.theme?.mode]);
|
|
91
|
+
|
|
92
|
+
// Initial fetch
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
Promise.all([
|
|
96
|
+
api.get<Snapshot>('/snapshot').catch(() => null),
|
|
97
|
+
api.get<SettingsResponse>('/settings').catch(() => null),
|
|
98
|
+
])
|
|
99
|
+
.then(([snap, set]) => {
|
|
100
|
+
if (cancelled) return;
|
|
101
|
+
if (snap) setSnapshot(snap);
|
|
102
|
+
if (set?.data) setSettings(set.data);
|
|
103
|
+
if (!snap && !set) setBootError('Dashboard server unreachable.');
|
|
104
|
+
if (set?.data?.ui?.defaultTab) setActiveTab(set.data.ui.defaultTab);
|
|
105
|
+
})
|
|
106
|
+
.catch((err) => {
|
|
107
|
+
if (cancelled) return;
|
|
108
|
+
const msg = (err as Error)?.message ?? 'unknown error';
|
|
109
|
+
setBootError(msg);
|
|
110
|
+
toast.error(`Failed to load: ${msg}`);
|
|
111
|
+
});
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
};
|
|
115
|
+
}, [toast]);
|
|
116
|
+
|
|
117
|
+
// WebSocket lifecycle
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const ws = new Ws();
|
|
120
|
+
wsRef.current = ws;
|
|
121
|
+
const offStatus = ws.onStatus((s) => setWsStatus(s));
|
|
122
|
+
const offMsg = ws.on((msg: WsMessage) => {
|
|
123
|
+
if (msg.type === 'snapshot' && 'data' in msg && msg.data) {
|
|
124
|
+
setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...msg.data }));
|
|
125
|
+
} else if (msg.type === 'change') {
|
|
126
|
+
const m = msg;
|
|
127
|
+
const file = m.path?.split('/').pop() || m.path || '';
|
|
128
|
+
toast.info(`File changed: ${file}`, 2500);
|
|
129
|
+
api
|
|
130
|
+
.get<Snapshot>('/snapshot')
|
|
131
|
+
.then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
|
|
132
|
+
.catch(() => undefined);
|
|
133
|
+
} else if (msg.type === 'tasks:change') {
|
|
134
|
+
const m = msg;
|
|
135
|
+
setSnapshot((cur) => {
|
|
136
|
+
if (!cur) return cur;
|
|
137
|
+
const tasks = (cur.tasks || []).map((t) =>
|
|
138
|
+
t.id === m.task.id ? m.task : t,
|
|
139
|
+
);
|
|
140
|
+
const exists = tasks.some((t) => t.id === m.task.id);
|
|
141
|
+
return { ...cur, tasks: exists ? tasks : [m.task, ...tasks] };
|
|
142
|
+
});
|
|
143
|
+
} else if (msg.type === 'tasks:delete') {
|
|
144
|
+
const m = msg;
|
|
145
|
+
setSnapshot((cur) =>
|
|
146
|
+
cur
|
|
147
|
+
? { ...cur, tasks: (cur.tasks || []).filter((t) => t.id !== m.id) }
|
|
148
|
+
: cur,
|
|
149
|
+
);
|
|
150
|
+
} else if (msg.type === 'settings:change') {
|
|
151
|
+
const m = msg;
|
|
152
|
+
if (m.settings) setSettings(m.settings);
|
|
153
|
+
} else if (msg.type === 'project:change') {
|
|
154
|
+
// Refresh the whole snapshot to pick up the new active project
|
|
155
|
+
api
|
|
156
|
+
.get<Snapshot>('/snapshot')
|
|
157
|
+
.then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
|
|
158
|
+
.catch(() => undefined);
|
|
159
|
+
} else if (msg.type === 'agents:change' || msg.type === 'schedules:change') {
|
|
160
|
+
api
|
|
161
|
+
.get<Snapshot>('/snapshot')
|
|
162
|
+
.then((s) => setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s })))
|
|
163
|
+
.catch(() => undefined);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return () => {
|
|
167
|
+
offStatus();
|
|
168
|
+
offMsg();
|
|
169
|
+
ws.close();
|
|
170
|
+
};
|
|
171
|
+
}, [toast]);
|
|
172
|
+
|
|
173
|
+
// Keyboard shortcuts
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const map: Record<string, string> = {};
|
|
176
|
+
TABS.forEach((t, i) => {
|
|
177
|
+
map[String(i + 1)] = t.id;
|
|
178
|
+
});
|
|
179
|
+
const handler = (e: KeyboardEvent) => {
|
|
180
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
setSearchOpen(true);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (e.key === '/' && !e.metaKey && !e.ctrlKey) {
|
|
186
|
+
const t = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
|
187
|
+
if (t !== 'input' && t !== 'textarea' && !((e.target as HTMLElement)?.isContentEditable)) {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
setSearchOpen(true);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const t = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
|
194
|
+
if (
|
|
195
|
+
t === 'input' ||
|
|
196
|
+
t === 'textarea' ||
|
|
197
|
+
(e.target as HTMLElement)?.isContentEditable ||
|
|
198
|
+
e.metaKey ||
|
|
199
|
+
e.ctrlKey ||
|
|
200
|
+
e.altKey
|
|
201
|
+
)
|
|
202
|
+
return;
|
|
203
|
+
const id = map[e.key];
|
|
204
|
+
if (id) {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
setActiveTab(id);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
document.addEventListener('keydown', handler);
|
|
210
|
+
return () => document.removeEventListener('keydown', handler);
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
const View = VIEW_MAP[activeTab];
|
|
214
|
+
|
|
215
|
+
const refreshSnapshot = useMemo(
|
|
216
|
+
() => async () => {
|
|
217
|
+
try {
|
|
218
|
+
const s = await api.get<Snapshot>('/snapshot');
|
|
219
|
+
setSnapshot((cur) => ({ ...(cur ?? ({} as Snapshot)), ...s }));
|
|
220
|
+
} catch (err) {
|
|
221
|
+
toast.error(`Refresh failed: ${(err as Error).message}`);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
[toast],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const refreshProjects = async () => {
|
|
228
|
+
try {
|
|
229
|
+
const r = await api.get<{ projects: ProjectRecord[]; active: string | null }>('/projects');
|
|
230
|
+
setSnapshot((cur) =>
|
|
231
|
+
cur ? { ...cur, projects: r.projects || [], activeProject: r.projects?.find((p) => p.id === r.active) || null } : cur,
|
|
232
|
+
);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
toast.error(`Projects refresh failed: ${(err as Error).message}`);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const onActivateProject = async (id: string) => {
|
|
239
|
+
try {
|
|
240
|
+
await api.post(`/projects/${encodeURIComponent(id)}/activate`);
|
|
241
|
+
// WS will fire project:change and refresh; but also reflect locally
|
|
242
|
+
setSnapshot((cur) => {
|
|
243
|
+
if (!cur) return cur;
|
|
244
|
+
return {
|
|
245
|
+
...cur,
|
|
246
|
+
activeProject: cur.projects.find((p) => p.id === id) || null,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
toast.error(`Activate failed: ${(err as Error).message}`);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const onSearchSelect = (r: SearchResult) => {
|
|
255
|
+
const t = r.type;
|
|
256
|
+
if (t === 'agent') setActiveTab('agents');
|
|
257
|
+
else if (t === 'task') setActiveTab('tasks');
|
|
258
|
+
else if (t === 'mod') setActiveTab('mods');
|
|
259
|
+
else if (t === 'schedule') setActiveTab('schedules');
|
|
260
|
+
else if (t === 'project') {
|
|
261
|
+
const id = (r.item as ProjectRecord).id;
|
|
262
|
+
onActivateProject(id);
|
|
263
|
+
} else if (t === 'command') {
|
|
264
|
+
// No command page; just toast
|
|
265
|
+
toast.info(`/${(r.item as { name: string }).name} — run from the TUI`, 2500);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div className="app" data-layout={settings?.ui?.layout || 'topnav'}>
|
|
271
|
+
<Topbar
|
|
272
|
+
activeTab={activeTab}
|
|
273
|
+
onTabChange={setActiveTab}
|
|
274
|
+
wsStatus={wsStatus}
|
|
275
|
+
version={VERSION}
|
|
276
|
+
activeProject={snapshot?.activeProject || null}
|
|
277
|
+
projects={snapshot?.projects || []}
|
|
278
|
+
onProjectChange={onActivateProject}
|
|
279
|
+
onProjectsRefresh={refreshProjects}
|
|
280
|
+
onOpenSearch={() => setSearchOpen(true)}
|
|
281
|
+
/>
|
|
282
|
+
<main className="content">
|
|
283
|
+
{bootError && (
|
|
284
|
+
<div className="boot-error">
|
|
285
|
+
<h2>Dashboard unavailable</h2>
|
|
286
|
+
<p>{bootError}</p>
|
|
287
|
+
<p className="boot-error-hint">
|
|
288
|
+
Make sure the Bizar dashboard server is running. Try{' '}
|
|
289
|
+
<code>bizar-dash start</code> in your terminal.
|
|
290
|
+
</p>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
{!bootError && (!snapshot || !settings) && (
|
|
294
|
+
<div className="loading">
|
|
295
|
+
<Spinner size="lg" />
|
|
296
|
+
<p>Loading Bizar…</p>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
{snapshot && settings && View && (
|
|
300
|
+
<View
|
|
301
|
+
snapshot={snapshot}
|
|
302
|
+
settings={settings}
|
|
303
|
+
activeTab={activeTab}
|
|
304
|
+
setActiveTab={setActiveTab}
|
|
305
|
+
refreshSnapshot={refreshSnapshot}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
308
|
+
</main>
|
|
309
|
+
<SearchModal
|
|
310
|
+
open={searchOpen}
|
|
311
|
+
onClose={() => setSearchOpen(false)}
|
|
312
|
+
onSelect={onSearchSelect}
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/components/Button.tsx — typed button with variants + sizes.
|
|
2
|
+
|
|
3
|
+
import { type ButtonHTMLAttributes, forwardRef } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
export type ButtonVariant =
|
|
7
|
+
| 'primary'
|
|
8
|
+
| 'secondary'
|
|
9
|
+
| 'ghost'
|
|
10
|
+
| 'danger'
|
|
11
|
+
| 'accent';
|
|
12
|
+
export type ButtonSize = 'sm' | 'md' | 'lg';
|
|
13
|
+
|
|
14
|
+
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
15
|
+
variant?: ButtonVariant;
|
|
16
|
+
size?: ButtonSize;
|
|
17
|
+
iconOnly?: boolean;
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
22
|
+
function Button(
|
|
23
|
+
{
|
|
24
|
+
variant = 'secondary',
|
|
25
|
+
size = 'md',
|
|
26
|
+
iconOnly = false,
|
|
27
|
+
loading = false,
|
|
28
|
+
disabled,
|
|
29
|
+
className,
|
|
30
|
+
children,
|
|
31
|
+
...rest
|
|
32
|
+
},
|
|
33
|
+
ref,
|
|
34
|
+
) {
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
ref={ref}
|
|
38
|
+
type={rest.type ?? 'button'}
|
|
39
|
+
disabled={disabled || loading}
|
|
40
|
+
className={cn(
|
|
41
|
+
'btn',
|
|
42
|
+
`btn-${variant}`,
|
|
43
|
+
`btn-size-${size}`,
|
|
44
|
+
iconOnly && 'btn-icon',
|
|
45
|
+
loading && 'btn-loading',
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
{...rest}
|
|
49
|
+
>
|
|
50
|
+
{loading ? <span className="btn-spinner" aria-hidden /> : null}
|
|
51
|
+
{children}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/components/Card.tsx — wrapper card.
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
export type CardProps = HTMLAttributes<HTMLDivElement> & {
|
|
7
|
+
variant?: 'elevated' | 'outlined' | 'filled';
|
|
8
|
+
interactive?: boolean;
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function Card({
|
|
13
|
+
variant = 'elevated',
|
|
14
|
+
interactive = false,
|
|
15
|
+
className,
|
|
16
|
+
children,
|
|
17
|
+
...rest
|
|
18
|
+
}: CardProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
'card',
|
|
23
|
+
`card-${variant}`,
|
|
24
|
+
interactive && 'card-interactive',
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
{...rest}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function CardTitle({ children, className }: { children: ReactNode; className?: string }) {
|
|
35
|
+
return <h3 className={cn('card-title', className)}>{children}</h3>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function CardMeta({ children, className }: { children: ReactNode; className?: string }) {
|
|
39
|
+
return <div className={cn('card-meta', className)}>{children}</div>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/components/EmptyState.tsx — friendly empty / error placeholder.
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { Inbox } from 'lucide-react';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
|
|
7
|
+
export type EmptyStateProps = {
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
title: string;
|
|
10
|
+
message?: ReactNode;
|
|
11
|
+
action?: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function EmptyState({
|
|
16
|
+
icon,
|
|
17
|
+
title,
|
|
18
|
+
message,
|
|
19
|
+
action,
|
|
20
|
+
className,
|
|
21
|
+
}: EmptyStateProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn('empty-state', className)}>
|
|
24
|
+
<div className="empty-icon">{icon ?? <Inbox size={32} />}</div>
|
|
25
|
+
<div className="empty-title">{title}</div>
|
|
26
|
+
{message && <div className="empty-message">{message}</div>}
|
|
27
|
+
{action && <div className="empty-action">{action}</div>}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/components/Modal.tsx — modal context + provider + portal.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
12
|
+
import { X } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
export type ModalProps = {
|
|
15
|
+
title?: string;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
footer?: ReactNode;
|
|
18
|
+
onClose?: () => void;
|
|
19
|
+
width?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ModalApi = {
|
|
23
|
+
open: (props: ModalProps) => string;
|
|
24
|
+
close: (id?: string) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ModalContext = createContext<ModalApi | null>(null);
|
|
28
|
+
|
|
29
|
+
export function useModal(): ModalApi {
|
|
30
|
+
const ctx = useContext(ModalContext);
|
|
31
|
+
if (!ctx) {
|
|
32
|
+
return { open: () => '', close: () => undefined };
|
|
33
|
+
}
|
|
34
|
+
return ctx;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type OpenModal = ModalProps & { id: string };
|
|
38
|
+
|
|
39
|
+
export function ModalProvider({ children }: { children: ReactNode }) {
|
|
40
|
+
const [stack, setStack] = useState<OpenModal[]>([]);
|
|
41
|
+
|
|
42
|
+
const close = useCallback((id?: string) => {
|
|
43
|
+
setStack((cur) => {
|
|
44
|
+
if (!id) return [];
|
|
45
|
+
const idx = cur.findIndex((m) => m.id === id);
|
|
46
|
+
if (idx === -1) return cur;
|
|
47
|
+
// close only the top-most matching
|
|
48
|
+
if (idx !== cur.length - 1) return cur;
|
|
49
|
+
return cur.slice(0, -1);
|
|
50
|
+
});
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const open = useCallback((props: ModalProps) => {
|
|
54
|
+
const id = `m${Math.random().toString(36).slice(2, 9)}`;
|
|
55
|
+
setStack((cur) => [...cur, { ...props, id }]);
|
|
56
|
+
return id;
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Escape key — close the topmost
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (stack.length === 0) return;
|
|
62
|
+
const handler = (e: KeyboardEvent) => {
|
|
63
|
+
if (e.key === 'Escape') {
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
close();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
document.addEventListener('keydown', handler);
|
|
69
|
+
return () => document.removeEventListener('keydown', handler);
|
|
70
|
+
}, [stack.length, close]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<ModalContext.Provider value={{ open, close }}>
|
|
74
|
+
{children}
|
|
75
|
+
{typeof document !== 'undefined' &&
|
|
76
|
+
createPortal(
|
|
77
|
+
<div className="modal-stack" aria-hidden={stack.length === 0}>
|
|
78
|
+
{stack.map((m, idx) => (
|
|
79
|
+
<ModalShell
|
|
80
|
+
key={m.id}
|
|
81
|
+
modal={m}
|
|
82
|
+
onClose={() => {
|
|
83
|
+
m.onClose?.();
|
|
84
|
+
close(m.id);
|
|
85
|
+
}}
|
|
86
|
+
depth={idx}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</div>,
|
|
90
|
+
document.body,
|
|
91
|
+
)}
|
|
92
|
+
</ModalContext.Provider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ModalShell({
|
|
97
|
+
modal,
|
|
98
|
+
onClose,
|
|
99
|
+
depth,
|
|
100
|
+
}: {
|
|
101
|
+
modal: OpenModal;
|
|
102
|
+
onClose: () => void;
|
|
103
|
+
depth: number;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
className="modal-backdrop"
|
|
108
|
+
onClick={(e) => {
|
|
109
|
+
if (e.target === e.currentTarget) onClose();
|
|
110
|
+
}}
|
|
111
|
+
style={depth > 0 ? { background: 'rgba(0,0,0,0.4)' } : undefined}
|
|
112
|
+
>
|
|
113
|
+
<div
|
|
114
|
+
className="modal"
|
|
115
|
+
role="dialog"
|
|
116
|
+
aria-modal="true"
|
|
117
|
+
style={modal.width ? { maxWidth: modal.width } : undefined}
|
|
118
|
+
>
|
|
119
|
+
{modal.title && (
|
|
120
|
+
<header className="modal-header">
|
|
121
|
+
<h2 className="modal-title">{modal.title}</h2>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className="icon-btn"
|
|
125
|
+
aria-label="Close"
|
|
126
|
+
onClick={onClose}
|
|
127
|
+
>
|
|
128
|
+
<X size={16} />
|
|
129
|
+
</button>
|
|
130
|
+
</header>
|
|
131
|
+
)}
|
|
132
|
+
<div className="modal-body">{modal.children}</div>
|
|
133
|
+
{modal.footer && <footer className="modal-footer">{modal.footer}</footer>}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|