@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.
Files changed (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -0,0 +1,185 @@
1
+ // src/components/SearchModal.tsx — fuzzy search modal opened by / or Cmd/Ctrl+K.
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Search, X } from 'lucide-react';
4
+ import { useToast } from './Toast';
5
+ import { api } from '../lib/api';
6
+ import type { SearchResult } from '../lib/types';
7
+ import { cn } from '../lib/utils';
8
+
9
+ type Props = {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ onSelect: (r: SearchResult) => void;
13
+ };
14
+
15
+ const SCOPES = [
16
+ { id: 'all', label: 'All' },
17
+ { id: 'projects', label: 'Projects' },
18
+ { id: 'agents', label: 'Agents' },
19
+ { id: 'tasks', label: 'Tasks' },
20
+ { id: 'mods', label: 'Mods' },
21
+ { id: 'schedules', label: 'Schedules' },
22
+ { id: 'commands', label: 'Commands' },
23
+ ] as const;
24
+
25
+ export function SearchModal({ open, onClose, onSelect }: Props) {
26
+ const toast = useToast();
27
+ const [q, setQ] = useState('');
28
+ const [scope, setScope] = useState<typeof SCOPES[number]['id']>('all');
29
+ const [results, setResults] = useState<SearchResult[]>([]);
30
+ const [loading, setLoading] = useState(false);
31
+ const [activeIdx, setActiveIdx] = useState(0);
32
+ const inputRef = useRef<HTMLInputElement>(null);
33
+
34
+ useEffect(() => {
35
+ if (open) {
36
+ setQ('');
37
+ setResults([]);
38
+ setActiveIdx(0);
39
+ setTimeout(() => inputRef.current?.focus(), 30);
40
+ }
41
+ }, [open]);
42
+
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ if (!q.trim()) {
46
+ setResults([]);
47
+ return;
48
+ }
49
+ let cancelled = false;
50
+ setLoading(true);
51
+ const t = setTimeout(() => {
52
+ api
53
+ .get<{ results: SearchResult[] }>(`/search?q=${encodeURIComponent(q)}&scope=${scope}`)
54
+ .then((r) => {
55
+ if (cancelled) return;
56
+ setResults(r.results || []);
57
+ setActiveIdx(0);
58
+ })
59
+ .catch((err) => {
60
+ if (cancelled) return;
61
+ toast.error(`Search failed: ${(err as Error).message}`);
62
+ })
63
+ .finally(() => !cancelled && setLoading(false));
64
+ }, 150);
65
+ return () => {
66
+ cancelled = true;
67
+ clearTimeout(t);
68
+ };
69
+ }, [q, scope, open, toast]);
70
+
71
+ if (!open) return null;
72
+
73
+ const grouped: Record<string, SearchResult[]> = {};
74
+ for (const r of results) {
75
+ grouped[r.type] = grouped[r.type] || [];
76
+ grouped[r.type].push(r);
77
+ }
78
+
79
+ const flat: SearchResult[] = [];
80
+ for (const scope of SCOPES.map((s) => s.id)) {
81
+ if (grouped[scope]) flat.push(...grouped[scope]);
82
+ }
83
+
84
+ const onKey = (e: React.KeyboardEvent) => {
85
+ if (e.key === 'Escape') {
86
+ onClose();
87
+ } else if (e.key === 'ArrowDown') {
88
+ e.preventDefault();
89
+ setActiveIdx((i) => Math.min(i + 1, flat.length - 1));
90
+ } else if (e.key === 'ArrowUp') {
91
+ e.preventDefault();
92
+ setActiveIdx((i) => Math.max(i - 1, 0));
93
+ } else if (e.key === 'Enter' && flat[activeIdx]) {
94
+ e.preventDefault();
95
+ onSelect(flat[activeIdx]);
96
+ onClose();
97
+ }
98
+ };
99
+
100
+ return (
101
+ <div className="search-modal-backdrop" onClick={onClose}>
102
+ <div className="search-modal" onClick={(e) => e.stopPropagation()}>
103
+ <div className="search-modal-head">
104
+ <Search size={14} />
105
+ <input
106
+ ref={inputRef}
107
+ className="search-modal-input"
108
+ placeholder="Search tasks, plans, agents, projects, mods…"
109
+ value={q}
110
+ onChange={(e) => setQ(e.target.value)}
111
+ onKeyDown={onKey}
112
+ />
113
+ <button
114
+ type="button"
115
+ className="icon-btn"
116
+ aria-label="Close"
117
+ onClick={onClose}
118
+ >
119
+ <X size={14} />
120
+ </button>
121
+ </div>
122
+ <div className="search-modal-scopes">
123
+ {SCOPES.map((s) => (
124
+ <button
125
+ key={s.id}
126
+ type="button"
127
+ className={cn('search-scope', scope === s.id && 'search-scope-active')}
128
+ onClick={() => setScope(s.id)}
129
+ >
130
+ {s.label}
131
+ </button>
132
+ ))}
133
+ </div>
134
+ <div className="search-modal-body">
135
+ {loading && <div className="muted">Searching…</div>}
136
+ {!loading && q && flat.length === 0 && <div className="muted">No results.</div>}
137
+ {!loading && !q && <div className="muted">Type to search…</div>}
138
+ {SCOPES.map((s) => {
139
+ const list = grouped[s.id];
140
+ if (!list || list.length === 0) return null;
141
+ return (
142
+ <div key={s.id} className="search-group">
143
+ <div className="search-group-head">{s.label}</div>
144
+ {list.map((r) => {
145
+ const flatIdx = flat.indexOf(r);
146
+ return (
147
+ <button
148
+ type="button"
149
+ key={`${r.type}-${flatIdx}`}
150
+ className={cn('search-result', flatIdx === activeIdx && 'search-result-active')}
151
+ onMouseEnter={() => setActiveIdx(flatIdx)}
152
+ onClick={() => {
153
+ onSelect(r);
154
+ onClose();
155
+ }}
156
+ >
157
+ <span className="search-result-type">{r.type}</span>
158
+ <span className="search-result-label">
159
+ {summarize(r)}
160
+ </span>
161
+ </button>
162
+ );
163
+ })}
164
+ </div>
165
+ );
166
+ })}
167
+ </div>
168
+ <div className="search-modal-foot">
169
+ <span className="muted">↑↓ navigate · ↵ open · esc close</span>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ function summarize(r: SearchResult): string {
177
+ const i = r.item || {};
178
+ if (r.type === 'project') return `${i.name} — ${i.path}`;
179
+ if (r.type === 'agent') return `${i.name} — ${i.description || i.model || ''}`;
180
+ if (r.type === 'task') return `${i.title} — ${(i.status || '')}`;
181
+ if (r.type === 'mod') return `${i.name} v${i.version} — ${i.description || ''}`;
182
+ if (r.type === 'schedule') return `${i.name} (${i.type}: ${i.schedule})`;
183
+ if (r.type === 'command') return `${i.name} — ${i.description || ''}`;
184
+ return JSON.stringify(i).slice(0, 80);
185
+ }
@@ -0,0 +1,19 @@
1
+ // src/components/Spinner.tsx — loading indicator.
2
+
3
+ import { cn } from '../lib/utils';
4
+
5
+ export type SpinnerProps = {
6
+ size?: 'sm' | 'md' | 'lg';
7
+ className?: string;
8
+ label?: string;
9
+ };
10
+
11
+ export function Spinner({ size = 'md', className, label }: SpinnerProps) {
12
+ return (
13
+ <span
14
+ className={cn('spinner', `spinner-${size}`, className)}
15
+ role="status"
16
+ aria-label={label || 'Loading'}
17
+ />
18
+ );
19
+ }
@@ -0,0 +1,25 @@
1
+ // src/components/StatusBadge.tsx — small colored pill.
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { cn } from '../lib/utils';
5
+
6
+ export type StatusBadgeProps = {
7
+ kind?: 'success' | 'warning' | 'error' | 'info' | 'accent' | 'neutral';
8
+ children: ReactNode;
9
+ className?: string;
10
+ dot?: boolean;
11
+ };
12
+
13
+ export function StatusBadge({
14
+ kind = 'neutral',
15
+ children,
16
+ className,
17
+ dot = false,
18
+ }: StatusBadgeProps) {
19
+ return (
20
+ <span className={cn('badge', `badge-${kind}`, className)}>
21
+ {dot && <span className="badge-dot" />}
22
+ {children}
23
+ </span>
24
+ );
25
+ }
@@ -0,0 +1,28 @@
1
+ // src/components/Tag.tsx — pill for tags.
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { cn } from '../lib/utils';
5
+
6
+ export type TagProps = {
7
+ children: ReactNode;
8
+ className?: string;
9
+ onRemove?: () => void;
10
+ };
11
+
12
+ export function Tag({ children, className, onRemove }: TagProps) {
13
+ return (
14
+ <span className={cn('tag', className)}>
15
+ {children}
16
+ {onRemove && (
17
+ <button
18
+ type="button"
19
+ className="tag-remove"
20
+ aria-label={`Remove ${typeof children === 'string' ? children : 'tag'}`}
21
+ onClick={onRemove}
22
+ >
23
+ ×
24
+ </button>
25
+ )}
26
+ </span>
27
+ );
28
+ }
@@ -0,0 +1,142 @@
1
+ // src/components/Toast.tsx — toast context, provider hook, container.
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { CheckCircle2, AlertTriangle, AlertCircle, Info, X } from 'lucide-react';
12
+
13
+ export type ToastKind = 'info' | 'success' | 'error' | 'warning';
14
+
15
+ export type Toast = {
16
+ id: number;
17
+ kind: ToastKind;
18
+ message: string;
19
+ };
20
+
21
+ export type ToastApi = {
22
+ show: (message: string, kind?: ToastKind, durationMs?: number) => void;
23
+ info: (message: string, durationMs?: number) => void;
24
+ success: (message: string, durationMs?: number) => void;
25
+ error: (message: string, durationMs?: number) => void;
26
+ warning: (message: string, durationMs?: number) => void;
27
+ dismiss: (id: number) => void;
28
+ toasts: Toast[];
29
+ };
30
+
31
+ const ToastContext = createContext<ToastApi | null>(null);
32
+
33
+ export function useToast(): ToastApi {
34
+ const ctx = useContext(ToastContext);
35
+ if (!ctx) {
36
+ // Fallback no-op so tests/Storybook don't crash
37
+ return {
38
+ show: () => undefined,
39
+ info: () => undefined,
40
+ success: () => undefined,
41
+ error: () => undefined,
42
+ warning: () => undefined,
43
+ dismiss: () => undefined,
44
+ toasts: [],
45
+ };
46
+ }
47
+ return ctx;
48
+ }
49
+
50
+ const ICONS: Record<ToastKind, typeof Info> = {
51
+ info: Info,
52
+ success: CheckCircle2,
53
+ error: AlertCircle,
54
+ warning: AlertTriangle,
55
+ };
56
+
57
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
58
+ const [toasts, setToasts] = useState<Toast[]>([]);
59
+ const idRef = useRef(1);
60
+ const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
61
+
62
+ const dismiss = useCallback((id: number) => {
63
+ setToasts((cur) => cur.filter((t) => t.id !== id));
64
+ const timer = timersRef.current.get(id);
65
+ if (timer) {
66
+ clearTimeout(timer);
67
+ timersRef.current.delete(id);
68
+ }
69
+ }, []);
70
+
71
+ const show = useCallback<ToastApi['show']>(
72
+ (message, kind = 'info', durationMs = 4000) => {
73
+ const id = idRef.current++;
74
+ setToasts((cur) => [...cur, { id, kind, message }]);
75
+ if (durationMs > 0) {
76
+ const timer = setTimeout(() => dismiss(id), durationMs);
77
+ timersRef.current.set(id, timer);
78
+ }
79
+ },
80
+ [dismiss],
81
+ );
82
+
83
+ const api = useMemo<ToastApi>(
84
+ () => ({
85
+ show,
86
+ info: (m, d) => show(m, 'info', d),
87
+ success: (m, d) => show(m, 'success', d),
88
+ error: (m, d) => show(m, 'error', d),
89
+ warning: (m, d) => show(m, 'warning', d),
90
+ dismiss,
91
+ toasts,
92
+ }),
93
+ [show, dismiss, toasts],
94
+ );
95
+
96
+ return (
97
+ <ToastContext.Provider value={api}>
98
+ {children}
99
+ <ToastContainer toasts={toasts} onDismiss={dismiss} />
100
+ </ToastContext.Provider>
101
+ );
102
+ }
103
+
104
+ export function ToastContainer({
105
+ toasts,
106
+ onDismiss,
107
+ }: {
108
+ toasts: Toast[];
109
+ onDismiss: (id: number) => void;
110
+ }) {
111
+ return (
112
+ <div className="toast-stack" aria-live="polite">
113
+ {toasts.map((t) => (
114
+ <ToastItem key={t.id} toast={t} onDismiss={onDismiss} />
115
+ ))}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function ToastItem({
121
+ toast,
122
+ onDismiss,
123
+ }: {
124
+ toast: Toast;
125
+ onDismiss: (id: number) => void;
126
+ }) {
127
+ const Icon = ICONS[toast.kind];
128
+ return (
129
+ <div className={`toast toast-${toast.kind}`} role="status">
130
+ <Icon size={16} className="toast-icon" />
131
+ <span className="toast-message">{toast.message}</span>
132
+ <button
133
+ type="button"
134
+ className="toast-close"
135
+ aria-label="Dismiss"
136
+ onClick={() => onDismiss(toast.id)}
137
+ >
138
+ <X size={14} />
139
+ </button>
140
+ </div>
141
+ );
142
+ }
@@ -0,0 +1,203 @@
1
+ // src/components/Topbar.tsx — header with brand, project selector, search, tabs, ws status.
2
+ import { useEffect, useState } from 'react';
3
+ import type { ReactNode } from 'react';
4
+ import {
5
+ LayoutDashboard,
6
+ MessageSquare,
7
+ Bot,
8
+ Map,
9
+ Folder,
10
+ CheckSquare,
11
+ Settings2,
12
+ Sliders,
13
+ Puzzle,
14
+ Clock,
15
+ Search as SearchIcon,
16
+ ChevronDown,
17
+ Plus,
18
+ RefreshCw,
19
+ Power,
20
+ type LucideIcon,
21
+ } from 'lucide-react';
22
+ import { cn } from '../lib/utils';
23
+ import type { ProjectRecord, WsStatus } from '../lib/types';
24
+ import { api } from '../lib/api';
25
+
26
+ export type TabDef = {
27
+ id: string;
28
+ label: string;
29
+ icon: LucideIcon;
30
+ };
31
+
32
+ export const TABS: TabDef[] = [
33
+ { id: 'overview', label: 'Overview', icon: LayoutDashboard },
34
+ { id: 'chat', label: 'Chat', icon: MessageSquare },
35
+ { id: 'agents', label: 'Agents', icon: Bot },
36
+ { id: 'plans', label: 'Plans', icon: Map },
37
+ { id: 'tasks', label: 'Tasks', icon: CheckSquare },
38
+ { id: 'mods', label: 'Mods', icon: Puzzle },
39
+ { id: 'schedules', label: 'Schedules', icon: Clock },
40
+ { id: 'config', label: 'Config', icon: Settings2 },
41
+ { id: 'settings', label: 'Settings', icon: Sliders },
42
+ ];
43
+
44
+ export type TopbarProps = {
45
+ activeTab: string;
46
+ onTabChange: (id: string) => void;
47
+ wsStatus: WsStatus;
48
+ version: string;
49
+ activeProject: ProjectRecord | null;
50
+ projects: ProjectRecord[];
51
+ onProjectChange: (id: string) => void;
52
+ onProjectsRefresh: () => void;
53
+ onOpenSearch: () => void;
54
+ rightSlot?: ReactNode;
55
+ };
56
+
57
+ export function Topbar({
58
+ activeTab,
59
+ onTabChange,
60
+ wsStatus,
61
+ version,
62
+ activeProject,
63
+ projects,
64
+ onProjectChange,
65
+ onProjectsRefresh,
66
+ onOpenSearch,
67
+ rightSlot,
68
+ }: TopbarProps) {
69
+ return (
70
+ <header className="topbar">
71
+ <div className="brand">
72
+ <span className="brand-logo" aria-hidden>🪩</span>
73
+ <span className="brand-title">Bizar</span>
74
+ <span className="brand-version">{version}</span>
75
+ </div>
76
+ <ProjectSelector
77
+ activeProject={activeProject}
78
+ projects={projects}
79
+ onChange={onProjectChange}
80
+ onRefresh={onProjectsRefresh}
81
+ />
82
+ <button
83
+ type="button"
84
+ className="topbar-search"
85
+ onClick={onOpenSearch}
86
+ title="Search (Ctrl/Cmd+K)"
87
+ >
88
+ <SearchIcon size={14} />
89
+ <span className="muted">Search…</span>
90
+ <kbd>⌘K</kbd>
91
+ </button>
92
+ <nav className="tabs" role="tablist">
93
+ {TABS.map((tab) => {
94
+ const Icon = tab.icon;
95
+ const active = tab.id === activeTab;
96
+ return (
97
+ <button
98
+ key={tab.id}
99
+ type="button"
100
+ role="tab"
101
+ aria-selected={active}
102
+ className={cn('tab', active && 'tab-active')}
103
+ onClick={() => onTabChange(tab.id)}
104
+ title={tab.label}
105
+ >
106
+ <Icon size={14} className="tab-icon" />
107
+ <span className="tab-label">{tab.label}</span>
108
+ </button>
109
+ );
110
+ })}
111
+ </nav>
112
+ <div className="topbar-right">
113
+ {rightSlot}
114
+ <div className={cn('ws-status', `ws-${wsStatus}`)} title={`WebSocket: ${wsStatus}`}>
115
+ <span className="ws-dot" />
116
+ <span className="ws-label">{wsStatus}</span>
117
+ </div>
118
+ </div>
119
+ </header>
120
+ );
121
+ }
122
+
123
+ function ProjectSelector({
124
+ activeProject,
125
+ projects,
126
+ onChange,
127
+ onRefresh,
128
+ }: {
129
+ activeProject: ProjectRecord | null;
130
+ projects: ProjectRecord[];
131
+ onChange: (id: string) => void;
132
+ onRefresh: () => void;
133
+ }) {
134
+ const [open, setOpen] = useState(false);
135
+
136
+ useEffect(() => {
137
+ if (!open) return;
138
+ const onClick = () => setOpen(false);
139
+ document.addEventListener('click', onClick);
140
+ return () => document.removeEventListener('click', onClick);
141
+ }, [open]);
142
+
143
+ const onAdd = async () => {
144
+ const path = (prompt('Project path:') || '').trim();
145
+ if (!path) return;
146
+ try {
147
+ await api.post('/projects', { path });
148
+ onRefresh();
149
+ } catch (err) {
150
+ alert(`Add failed: ${(err as Error).message}`);
151
+ }
152
+ };
153
+
154
+ return (
155
+ <div className="project-selector" onClick={(e) => e.stopPropagation()}>
156
+ <button
157
+ type="button"
158
+ className="project-selector-btn"
159
+ onClick={() => setOpen((v) => !v)}
160
+ title="Switch active project"
161
+ >
162
+ <Folder size={14} />
163
+ <span className="project-selector-name">
164
+ {activeProject?.name || '(no project)'}
165
+ </span>
166
+ <ChevronDown size={12} />
167
+ </button>
168
+ {open && (
169
+ <div className="project-selector-menu">
170
+ <div className="project-selector-menu-head">
171
+ <span className="muted">{projects.length} project{projects.length === 1 ? '' : 's'}</span>
172
+ <div className="project-selector-menu-actions">
173
+ <button type="button" className="icon-btn" onClick={onRefresh} title="Refresh">
174
+ <RefreshCw size={12} />
175
+ </button>
176
+ <button type="button" className="icon-btn" onClick={onAdd} title="Add project">
177
+ <Plus size={12} />
178
+ </button>
179
+ </div>
180
+ </div>
181
+ <ul className="project-selector-list">
182
+ {projects.length === 0 && (
183
+ <li className="muted project-selector-empty">No projects. Add one to start.</li>
184
+ )}
185
+ {projects.map((p) => (
186
+ <li
187
+ key={p.id}
188
+ className={cn('project-selector-item', activeProject?.id === p.id && 'active')}
189
+ onClick={() => {
190
+ onChange(p.id);
191
+ setOpen(false);
192
+ }}
193
+ >
194
+ <span className="project-selector-item-name">{p.name}</span>
195
+ <span className="project-selector-item-status">{p.status}</span>
196
+ </li>
197
+ ))}
198
+ </ul>
199
+ </div>
200
+ )}
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,17 @@
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" />
6
+ <title>Bizar Dashboard</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🪩</text></svg>" />
8
+ <link rel="preconnect" href="https://rsms.me/" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
12
+ </head>
13
+ <body>
14
+ <div id="root"></div>
15
+ <script type="module" src="/main.tsx"></script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,71 @@
1
+ // src/lib/api.ts — REST client. Single instance, type-safe wrappers.
2
+
3
+ export class ApiError extends Error {
4
+ status: number;
5
+ data: unknown;
6
+ constructor(message: string, status: number, data?: unknown) {
7
+ super(message);
8
+ this.name = 'ApiError';
9
+ this.status = status;
10
+ this.data = data;
11
+ }
12
+ }
13
+
14
+ class ApiClient {
15
+ base = '/api';
16
+
17
+ async get<T>(path: string): Promise<T> {
18
+ return this.req<T>('GET', path);
19
+ }
20
+ async post<T>(path: string, body?: unknown): Promise<T> {
21
+ return this.req<T>('POST', path, body);
22
+ }
23
+ async put<T>(path: string, body?: unknown): Promise<T> {
24
+ return this.req<T>('PUT', path, body);
25
+ }
26
+ async patch<T>(path: string, body?: unknown): Promise<T> {
27
+ return this.req<T>('PATCH', path, body);
28
+ }
29
+ async del<T = unknown>(path: string): Promise<T> {
30
+ return this.req<T>('DELETE', path);
31
+ }
32
+
33
+ private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
34
+ const opts: RequestInit = {
35
+ method,
36
+ headers: { 'Content-Type': 'application/json' },
37
+ };
38
+ if (body !== undefined && body !== null) {
39
+ opts.body = typeof body === 'string' ? body : JSON.stringify(body);
40
+ }
41
+ const r = await fetch(this.base + path, opts);
42
+ const ct = r.headers.get('content-type') || '';
43
+ if (ct.includes('application/json')) {
44
+ const data = await r.json();
45
+ if (!r.ok) {
46
+ const msg =
47
+ (data && typeof data === 'object' && 'message' in data
48
+ ? (data as { message?: string }).message
49
+ : undefined) || `${method} ${path}: ${r.status}`;
50
+ throw new ApiError(msg, r.status, data);
51
+ }
52
+ return data as T;
53
+ }
54
+ const text = await r.text();
55
+ if (!r.ok) {
56
+ throw new ApiError(
57
+ `${method} ${path}: ${r.status} — ${text.slice(0, 200)}`,
58
+ r.status,
59
+ text,
60
+ );
61
+ }
62
+ try {
63
+ return JSON.parse(text) as T;
64
+ } catch {
65
+ return text as unknown as T;
66
+ }
67
+ }
68
+ }
69
+
70
+ export const api = new ApiClient();
71
+ export { ApiClient };