@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,667 @@
|
|
|
1
|
+
// src/views/Plans.tsx — list + new plan + canvas (pan/zoom).
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
Map,
|
|
5
|
+
RefreshCw,
|
|
6
|
+
Plus,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
MessageCircle,
|
|
9
|
+
Maximize2,
|
|
10
|
+
} from 'lucide-react';
|
|
11
|
+
import { Button } from '../components/Button';
|
|
12
|
+
import { Card, CardTitle, CardMeta } from '../components/Card';
|
|
13
|
+
import { EmptyState } from '../components/EmptyState';
|
|
14
|
+
import { Spinner } from '../components/Spinner';
|
|
15
|
+
import { StatusBadge } from '../components/StatusBadge';
|
|
16
|
+
import { useToast } from '../components/Toast';
|
|
17
|
+
import { api } from '../lib/api';
|
|
18
|
+
import {
|
|
19
|
+
cn,
|
|
20
|
+
formatRelative,
|
|
21
|
+
truncate,
|
|
22
|
+
} from '../lib/utils';
|
|
23
|
+
import type {
|
|
24
|
+
Canvas,
|
|
25
|
+
CanvasComment,
|
|
26
|
+
CanvasConnection,
|
|
27
|
+
CanvasElement,
|
|
28
|
+
Plan,
|
|
29
|
+
Settings,
|
|
30
|
+
Snapshot,
|
|
31
|
+
} from '../lib/types';
|
|
32
|
+
|
|
33
|
+
function planStatusKind(status: string): 'success' | 'info' | 'error' | 'accent' | 'neutral' {
|
|
34
|
+
switch (status) {
|
|
35
|
+
case 'approved':
|
|
36
|
+
case 'done':
|
|
37
|
+
return 'success';
|
|
38
|
+
case 'in-progress':
|
|
39
|
+
case 'doing':
|
|
40
|
+
return 'info';
|
|
41
|
+
case 'rejected':
|
|
42
|
+
return 'error';
|
|
43
|
+
case 'draft':
|
|
44
|
+
case 'queued':
|
|
45
|
+
default:
|
|
46
|
+
return 'neutral';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Props = {
|
|
51
|
+
snapshot: Snapshot;
|
|
52
|
+
settings: Settings;
|
|
53
|
+
activeTab: string;
|
|
54
|
+
setActiveTab: (id: string) => void;
|
|
55
|
+
refreshSnapshot: () => Promise<void>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function Plans({ snapshot }: Props) {
|
|
59
|
+
const toast = useToast();
|
|
60
|
+
const [plans, setPlans] = useState<Plan[]>(snapshot.plans || []);
|
|
61
|
+
const [loading, setLoading] = useState(!snapshot.plans);
|
|
62
|
+
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
|
63
|
+
const [filter, setFilter] = useState<string>('');
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (snapshot.plans?.length) {
|
|
67
|
+
setPlans(snapshot.plans);
|
|
68
|
+
setLoading(false);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
api
|
|
73
|
+
.get<{ plans: Plan[] }>('/plans')
|
|
74
|
+
.then((d) => {
|
|
75
|
+
if (!cancelled) {
|
|
76
|
+
setPlans(d.plans || []);
|
|
77
|
+
setLoading(false);
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.catch((err) => {
|
|
81
|
+
if (!cancelled) {
|
|
82
|
+
setLoading(false);
|
|
83
|
+
toast.error(`Could not load plans: ${(err as Error).message}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
89
|
+
}, [snapshot.plans, toast]);
|
|
90
|
+
|
|
91
|
+
const filtered = useMemo(
|
|
92
|
+
() =>
|
|
93
|
+
filter
|
|
94
|
+
? plans.filter((p) =>
|
|
95
|
+
(p.status || '').toLowerCase().includes(filter.toLowerCase()),
|
|
96
|
+
)
|
|
97
|
+
: plans,
|
|
98
|
+
[plans, filter],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const refresh = async () => {
|
|
102
|
+
try {
|
|
103
|
+
const d = await api.get<{ plans: Plan[] }>('/plans');
|
|
104
|
+
setPlans(d.plans || []);
|
|
105
|
+
toast.info('Plans refreshed.', 1500);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
toast.error(`Refresh failed: ${(err as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const onCreate = async (slug: string, title?: string) => {
|
|
112
|
+
try {
|
|
113
|
+
await api.post('/plans', { slug, title });
|
|
114
|
+
toast.success(`Plan "${slug}" created.`);
|
|
115
|
+
setSelectedSlug(slug);
|
|
116
|
+
await refresh();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const statuses = useMemo(
|
|
123
|
+
() => Array.from(new Set(plans.map((p) => p.status || 'draft'))),
|
|
124
|
+
[plans],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="view view-plans">
|
|
129
|
+
<header className="view-header">
|
|
130
|
+
<div className="view-header-text">
|
|
131
|
+
<h2 className="view-title">
|
|
132
|
+
<Map size={18} /> Plans ({plans.length})
|
|
133
|
+
</h2>
|
|
134
|
+
<p className="view-subtitle">
|
|
135
|
+
Visual plans with elements, connections, and threaded comments.
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="view-actions">
|
|
139
|
+
<select
|
|
140
|
+
className="select select-sm"
|
|
141
|
+
value={filter}
|
|
142
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
143
|
+
>
|
|
144
|
+
<option value="">All statuses</option>
|
|
145
|
+
{statuses.map((s) => (
|
|
146
|
+
<option key={s} value={s}>
|
|
147
|
+
{s}
|
|
148
|
+
</option>
|
|
149
|
+
))}
|
|
150
|
+
</select>
|
|
151
|
+
<Button variant="secondary" size="sm" onClick={refresh}>
|
|
152
|
+
<RefreshCw size={14} /> Refresh
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</header>
|
|
156
|
+
|
|
157
|
+
<NewPlanForm onCreate={onCreate} />
|
|
158
|
+
|
|
159
|
+
{loading ? (
|
|
160
|
+
<div className="view-loading">
|
|
161
|
+
<Spinner size="lg" />
|
|
162
|
+
</div>
|
|
163
|
+
) : plans.length === 0 ? (
|
|
164
|
+
<EmptyState
|
|
165
|
+
icon={<Map size={32} />}
|
|
166
|
+
title="No plans yet"
|
|
167
|
+
message="Create one above to get started."
|
|
168
|
+
/>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="plans-layout">
|
|
171
|
+
<div className="plans-list-col">
|
|
172
|
+
{filtered.map((p) => (
|
|
173
|
+
<PlanCard
|
|
174
|
+
key={p.slug}
|
|
175
|
+
plan={p}
|
|
176
|
+
selected={p.slug === selectedSlug}
|
|
177
|
+
onSelect={() => setSelectedSlug(p.slug)}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
{filtered.length === 0 && (
|
|
181
|
+
<p className="muted text-sm">No plans match that filter.</p>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
<div className="plans-canvas-col">
|
|
185
|
+
{selectedSlug ? (
|
|
186
|
+
<PlanCanvas slug={selectedSlug} />
|
|
187
|
+
) : (
|
|
188
|
+
<EmptyState
|
|
189
|
+
icon={<Map size={28} />}
|
|
190
|
+
title="Select a plan"
|
|
191
|
+
message="Pick a plan on the left to view its canvas."
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function NewPlanForm({
|
|
202
|
+
onCreate,
|
|
203
|
+
}: {
|
|
204
|
+
onCreate: (slug: string, title?: string) => Promise<void> | void;
|
|
205
|
+
}) {
|
|
206
|
+
const [slug, setSlug] = useState('');
|
|
207
|
+
const [title, setTitle] = useState('');
|
|
208
|
+
return (
|
|
209
|
+
<Card className="new-plan">
|
|
210
|
+
<CardTitle>
|
|
211
|
+
<Plus size={14} /> New plan
|
|
212
|
+
</CardTitle>
|
|
213
|
+
<CardMeta>
|
|
214
|
+
Slug must be lowercase, may contain hyphens, 1–64 chars.
|
|
215
|
+
</CardMeta>
|
|
216
|
+
<form
|
|
217
|
+
className="new-plan-form"
|
|
218
|
+
onSubmit={(e) => {
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
if (!slug.trim()) return;
|
|
221
|
+
onCreate(slug.trim(), title.trim() || undefined);
|
|
222
|
+
setSlug('');
|
|
223
|
+
setTitle('');
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<input
|
|
227
|
+
className="input"
|
|
228
|
+
type="text"
|
|
229
|
+
placeholder="slug (e.g. dashboard-v2.6)"
|
|
230
|
+
pattern="[a-z0-9][a-z0-9-]{0,63}"
|
|
231
|
+
required
|
|
232
|
+
value={slug}
|
|
233
|
+
onChange={(e) => setSlug(e.target.value)}
|
|
234
|
+
/>
|
|
235
|
+
<input
|
|
236
|
+
className="input"
|
|
237
|
+
type="text"
|
|
238
|
+
placeholder="Title (optional)"
|
|
239
|
+
value={title}
|
|
240
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
241
|
+
/>
|
|
242
|
+
<Button variant="primary" type="submit">
|
|
243
|
+
Create
|
|
244
|
+
</Button>
|
|
245
|
+
</form>
|
|
246
|
+
</Card>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function PlanCard({
|
|
251
|
+
plan,
|
|
252
|
+
selected,
|
|
253
|
+
onSelect,
|
|
254
|
+
}: {
|
|
255
|
+
plan: Plan;
|
|
256
|
+
selected: boolean;
|
|
257
|
+
onSelect: () => void;
|
|
258
|
+
}) {
|
|
259
|
+
const kind = planStatusKind(plan.status || 'draft');
|
|
260
|
+
return (
|
|
261
|
+
<Card
|
|
262
|
+
variant={selected ? 'filled' : 'elevated'}
|
|
263
|
+
interactive
|
|
264
|
+
className={cn('plan-card', selected && 'plan-card-selected')}
|
|
265
|
+
onClick={onSelect}
|
|
266
|
+
>
|
|
267
|
+
<div className="plan-card-head">
|
|
268
|
+
<div className="plan-card-title">{plan.title || plan.slug}</div>
|
|
269
|
+
<StatusBadge kind={kind}>{plan.status || 'draft'}</StatusBadge>
|
|
270
|
+
</div>
|
|
271
|
+
<div className="plan-card-slug mono">
|
|
272
|
+
{plan.slug} · {plan.source}
|
|
273
|
+
</div>
|
|
274
|
+
<div className="plan-card-meta">
|
|
275
|
+
{plan.elementCount != null && (
|
|
276
|
+
<span>{plan.elementCount} elements</span>
|
|
277
|
+
)}
|
|
278
|
+
{plan.commentCount != null && (
|
|
279
|
+
<span> · {plan.commentCount} comments</span>
|
|
280
|
+
)}
|
|
281
|
+
<span> · edited {formatRelative(plan.mtime)}</span>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="plan-card-actions">
|
|
284
|
+
<Button
|
|
285
|
+
variant="ghost"
|
|
286
|
+
size="sm"
|
|
287
|
+
onClick={(e) => {
|
|
288
|
+
e.stopPropagation();
|
|
289
|
+
onSelect();
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
View
|
|
293
|
+
</Button>
|
|
294
|
+
<Button
|
|
295
|
+
variant="ghost"
|
|
296
|
+
size="sm"
|
|
297
|
+
onClick={(e) => {
|
|
298
|
+
e.stopPropagation();
|
|
299
|
+
// We don't have a CLI bridge here; just nudge the user.
|
|
300
|
+
// eslint-disable-next-line no-alert
|
|
301
|
+
alert(
|
|
302
|
+
`Open this plan in the CLI:\n\nbizar plan open ${plan.slug}\n\nThe CLI launches the full visual editor with pan, zoom, and edit capabilities.`,
|
|
303
|
+
);
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
<ExternalLink size={12} /> Open
|
|
307
|
+
</Button>
|
|
308
|
+
</div>
|
|
309
|
+
</Card>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function PlanCanvas({ slug }: { slug: string }) {
|
|
314
|
+
const [canvas, setCanvas] = useState<Canvas | null>(null);
|
|
315
|
+
const [loading, setLoading] = useState(true);
|
|
316
|
+
const [selectedElId, setSelectedElId] = useState<string | null>(null);
|
|
317
|
+
|
|
318
|
+
const load = async () => {
|
|
319
|
+
setLoading(true);
|
|
320
|
+
try {
|
|
321
|
+
const d = await api.get<{ canvas: Canvas }>(
|
|
322
|
+
`/plans/${encodeURIComponent(slug)}/canvas`,
|
|
323
|
+
);
|
|
324
|
+
setCanvas(d.canvas);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// eslint-disable-next-line no-console
|
|
327
|
+
console.warn('canvas load failed', err);
|
|
328
|
+
setCanvas({
|
|
329
|
+
title: slug,
|
|
330
|
+
elements: [],
|
|
331
|
+
connections: [],
|
|
332
|
+
comments: [],
|
|
333
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
334
|
+
});
|
|
335
|
+
} finally {
|
|
336
|
+
setLoading(false);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
setSelectedElId(null);
|
|
342
|
+
load();
|
|
343
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
344
|
+
}, [slug]);
|
|
345
|
+
|
|
346
|
+
if (loading || !canvas) {
|
|
347
|
+
return (
|
|
348
|
+
<div className="view-loading">
|
|
349
|
+
<Spinner size="lg" />
|
|
350
|
+
<p>Loading canvas…</p>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const visibleComments = selectedElId
|
|
356
|
+
? canvas.comments.filter(
|
|
357
|
+
(c) => c.elementId === selectedElId || c.id === selectedElId,
|
|
358
|
+
)
|
|
359
|
+
: canvas.comments;
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<div className="plan-canvas">
|
|
363
|
+
<header className="plan-canvas-header">
|
|
364
|
+
<div>
|
|
365
|
+
<div className="plan-canvas-title">{canvas.title || slug}</div>
|
|
366
|
+
<div className="plan-canvas-meta">
|
|
367
|
+
{canvas.elements.length} element
|
|
368
|
+
{canvas.elements.length !== 1 ? 's' : ''} ·{' '}
|
|
369
|
+
{canvas.comments.length} comment
|
|
370
|
+
{canvas.comments.length !== 1 ? 's' : ''}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
<div className="plan-canvas-actions">
|
|
374
|
+
<Button variant="ghost" size="sm" onClick={load} title="Refresh canvas">
|
|
375
|
+
<RefreshCw size={14} />
|
|
376
|
+
</Button>
|
|
377
|
+
<Button
|
|
378
|
+
variant="ghost"
|
|
379
|
+
size="sm"
|
|
380
|
+
title="Reset zoom"
|
|
381
|
+
onClick={() => {
|
|
382
|
+
// Force remount via key trick
|
|
383
|
+
setCanvas({ ...canvas, viewport: { x: 0, y: 0, zoom: 1 } });
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
<Maximize2 size={14} />
|
|
387
|
+
</Button>
|
|
388
|
+
</div>
|
|
389
|
+
</header>
|
|
390
|
+
|
|
391
|
+
<div className="plan-canvas-area">
|
|
392
|
+
{canvas.elements.length === 0 ? (
|
|
393
|
+
<div className="plan-canvas-empty">
|
|
394
|
+
No elements yet. Add one via the CLI: <code>/plan add {slug} <type></code>
|
|
395
|
+
</div>
|
|
396
|
+
) : (
|
|
397
|
+
<CanvasViewport canvas={canvas}>
|
|
398
|
+
{canvas.elements.map((el) => (
|
|
399
|
+
<CanvasElementView
|
|
400
|
+
key={el.id}
|
|
401
|
+
element={el}
|
|
402
|
+
selected={selectedElId === el.id}
|
|
403
|
+
onClick={() =>
|
|
404
|
+
setSelectedElId(selectedElId === el.id ? null : el.id)
|
|
405
|
+
}
|
|
406
|
+
/>
|
|
407
|
+
))}
|
|
408
|
+
<Connections
|
|
409
|
+
elements={canvas.elements}
|
|
410
|
+
connections={canvas.connections}
|
|
411
|
+
/>
|
|
412
|
+
</CanvasViewport>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
<footer className="plan-canvas-comments">
|
|
417
|
+
<h4 className="plan-canvas-comments-title">
|
|
418
|
+
<MessageCircle size={14} /> Comments ({visibleComments.length}
|
|
419
|
+
{selectedElId ? ' for selected' : ''})
|
|
420
|
+
</h4>
|
|
421
|
+
{visibleComments.length === 0 ? (
|
|
422
|
+
<p className="muted text-sm">
|
|
423
|
+
{selectedElId
|
|
424
|
+
? 'No comments on this element yet.'
|
|
425
|
+
: 'No comments yet.'}
|
|
426
|
+
</p>
|
|
427
|
+
) : (
|
|
428
|
+
<ul className="comment-list">
|
|
429
|
+
{visibleComments.map((c) => (
|
|
430
|
+
<CommentItem key={c.id} comment={c} />
|
|
431
|
+
))}
|
|
432
|
+
</ul>
|
|
433
|
+
)}
|
|
434
|
+
</footer>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function CanvasViewport({
|
|
440
|
+
canvas,
|
|
441
|
+
children,
|
|
442
|
+
}: {
|
|
443
|
+
canvas: Canvas;
|
|
444
|
+
children: React.ReactNode;
|
|
445
|
+
}) {
|
|
446
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
447
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
448
|
+
const stateRef = useRef({
|
|
449
|
+
panning: false,
|
|
450
|
+
startX: 0,
|
|
451
|
+
startY: 0,
|
|
452
|
+
ox: canvas.viewport.x || 0,
|
|
453
|
+
oy: canvas.viewport.y || 0,
|
|
454
|
+
scale: canvas.viewport.zoom || 1,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const root = rootRef.current;
|
|
459
|
+
const inner = innerRef.current;
|
|
460
|
+
if (!root || !inner) return;
|
|
461
|
+
const apply = () => {
|
|
462
|
+
const s = stateRef.current;
|
|
463
|
+
inner.style.transform = `translate(${s.ox}px, ${s.oy}px) scale(${s.scale})`;
|
|
464
|
+
};
|
|
465
|
+
apply();
|
|
466
|
+
|
|
467
|
+
const onMouseDown = (e: MouseEvent) => {
|
|
468
|
+
const t = e.target as HTMLElement;
|
|
469
|
+
if (
|
|
470
|
+
t !== root &&
|
|
471
|
+
!t.classList.contains('canvas-grid-bg') &&
|
|
472
|
+
!t.classList.contains('canvas-inner')
|
|
473
|
+
)
|
|
474
|
+
return;
|
|
475
|
+
stateRef.current.panning = true;
|
|
476
|
+
stateRef.current.startX = e.clientX - stateRef.current.ox;
|
|
477
|
+
stateRef.current.startY = e.clientY - stateRef.current.oy;
|
|
478
|
+
root.style.cursor = 'grabbing';
|
|
479
|
+
};
|
|
480
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
481
|
+
if (!stateRef.current.panning) return;
|
|
482
|
+
stateRef.current.ox = e.clientX - stateRef.current.startX;
|
|
483
|
+
stateRef.current.oy = e.clientY - stateRef.current.startY;
|
|
484
|
+
apply();
|
|
485
|
+
};
|
|
486
|
+
const onMouseUp = () => {
|
|
487
|
+
if (!stateRef.current.panning) return;
|
|
488
|
+
stateRef.current.panning = false;
|
|
489
|
+
root.style.cursor = '';
|
|
490
|
+
};
|
|
491
|
+
const onWheel = (e: WheelEvent) => {
|
|
492
|
+
e.preventDefault();
|
|
493
|
+
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
494
|
+
const next = Math.min(
|
|
495
|
+
Math.max(stateRef.current.scale * delta, 0.2),
|
|
496
|
+
3,
|
|
497
|
+
);
|
|
498
|
+
stateRef.current.scale = next;
|
|
499
|
+
apply();
|
|
500
|
+
};
|
|
501
|
+
root.addEventListener('mousedown', onMouseDown);
|
|
502
|
+
window.addEventListener('mousemove', onMouseMove);
|
|
503
|
+
window.addEventListener('mouseup', onMouseUp);
|
|
504
|
+
root.addEventListener('wheel', onWheel, { passive: false });
|
|
505
|
+
return () => {
|
|
506
|
+
root.removeEventListener('mousedown', onMouseDown);
|
|
507
|
+
window.removeEventListener('mousemove', onMouseMove);
|
|
508
|
+
window.removeEventListener('mouseup', onMouseUp);
|
|
509
|
+
root.removeEventListener('wheel', onWheel);
|
|
510
|
+
};
|
|
511
|
+
}, [canvas.elements.length, canvas.connections.length]);
|
|
512
|
+
|
|
513
|
+
return (
|
|
514
|
+
<div className="canvas-root" ref={rootRef}>
|
|
515
|
+
<div className="canvas-grid-bg" />
|
|
516
|
+
<div className="canvas-inner" ref={innerRef}>
|
|
517
|
+
{children}
|
|
518
|
+
</div>
|
|
519
|
+
<div className="canvas-hint">
|
|
520
|
+
Pan: drag · Zoom: scroll
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function CanvasElementView({
|
|
527
|
+
element,
|
|
528
|
+
selected,
|
|
529
|
+
onClick,
|
|
530
|
+
}: {
|
|
531
|
+
element: CanvasElement;
|
|
532
|
+
selected: boolean;
|
|
533
|
+
onClick: () => void;
|
|
534
|
+
}) {
|
|
535
|
+
const x = element.x || 0;
|
|
536
|
+
const y = element.y || 0;
|
|
537
|
+
const w = element.width || 240;
|
|
538
|
+
const h = element.height || 160;
|
|
539
|
+
return (
|
|
540
|
+
<div
|
|
541
|
+
className={cn('canvas-element', selected && 'canvas-element-selected')}
|
|
542
|
+
style={{
|
|
543
|
+
left: x,
|
|
544
|
+
top: y,
|
|
545
|
+
width: w,
|
|
546
|
+
minHeight: h,
|
|
547
|
+
}}
|
|
548
|
+
onClick={(e) => {
|
|
549
|
+
e.stopPropagation();
|
|
550
|
+
onClick();
|
|
551
|
+
}}
|
|
552
|
+
>
|
|
553
|
+
<div className="canvas-element-type">{element.type || 'text'}</div>
|
|
554
|
+
<div className="canvas-element-title">{element.title || 'Untitled'}</div>
|
|
555
|
+
{element.content && (
|
|
556
|
+
<div className="canvas-element-content">
|
|
557
|
+
{truncate(element.content, 200)}
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function Connections({
|
|
565
|
+
elements,
|
|
566
|
+
connections,
|
|
567
|
+
}: {
|
|
568
|
+
elements: CanvasElement[];
|
|
569
|
+
connections: CanvasConnection[];
|
|
570
|
+
}) {
|
|
571
|
+
if (!connections.length) return null;
|
|
572
|
+
// Compute world bounding box so the SVG covers all elements regardless of pan/zoom.
|
|
573
|
+
const bbox = elements.reduce(
|
|
574
|
+
(acc, el) => {
|
|
575
|
+
const ex = el.x || 0;
|
|
576
|
+
const ey = el.y || 0;
|
|
577
|
+
const ex2 = ex + (el.width || 240);
|
|
578
|
+
const ey2 = ey + (el.height || 160);
|
|
579
|
+
return {
|
|
580
|
+
minX: Math.min(acc.minX, ex),
|
|
581
|
+
minY: Math.min(acc.minY, ey),
|
|
582
|
+
maxX: Math.max(acc.maxX, ex2),
|
|
583
|
+
maxY: Math.max(acc.maxY, ey2),
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
{ minX: 0, minY: 0, maxX: 0, maxY: 0 },
|
|
587
|
+
);
|
|
588
|
+
const pad = 80;
|
|
589
|
+
const w = bbox.maxX - bbox.minX + pad * 2;
|
|
590
|
+
const h = bbox.maxY - bbox.minY + pad * 2;
|
|
591
|
+
const ox = -bbox.minX + pad;
|
|
592
|
+
const oy = -bbox.minY + pad;
|
|
593
|
+
return (
|
|
594
|
+
<svg
|
|
595
|
+
className="canvas-connections"
|
|
596
|
+
width={w}
|
|
597
|
+
height={h}
|
|
598
|
+
style={{
|
|
599
|
+
left: bbox.minX - pad,
|
|
600
|
+
top: bbox.minY - pad,
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
<defs>
|
|
604
|
+
<marker
|
|
605
|
+
id="arrow"
|
|
606
|
+
viewBox="0 0 10 10"
|
|
607
|
+
refX="9"
|
|
608
|
+
refY="5"
|
|
609
|
+
markerWidth="6"
|
|
610
|
+
markerHeight="6"
|
|
611
|
+
orient="auto-start-reverse"
|
|
612
|
+
>
|
|
613
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--accent)" />
|
|
614
|
+
</marker>
|
|
615
|
+
</defs>
|
|
616
|
+
{connections.map((c) => {
|
|
617
|
+
const from = elements.find(
|
|
618
|
+
(e) => e.id === c.fromElementId || e.id === c.from,
|
|
619
|
+
);
|
|
620
|
+
const to = elements.find(
|
|
621
|
+
(e) => e.id === c.toElementId || e.id === c.to,
|
|
622
|
+
);
|
|
623
|
+
if (!from || !to) return null;
|
|
624
|
+
const x1 = ox + (from.x || 0) + (from.width || 240) / 2;
|
|
625
|
+
const y1 = oy + (from.y || 0) + (from.height || 160);
|
|
626
|
+
const x2 = ox + (to.x || 0) + (to.width || 240) / 2;
|
|
627
|
+
const y2 = oy + (to.y || 0);
|
|
628
|
+
return (
|
|
629
|
+
<line
|
|
630
|
+
key={c.id}
|
|
631
|
+
x1={x1}
|
|
632
|
+
y1={y1}
|
|
633
|
+
x2={x2}
|
|
634
|
+
y2={y2}
|
|
635
|
+
stroke="var(--accent)"
|
|
636
|
+
strokeWidth={1.5}
|
|
637
|
+
markerEnd="url(#arrow)"
|
|
638
|
+
/>
|
|
639
|
+
);
|
|
640
|
+
})}
|
|
641
|
+
</svg>
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function CommentItem({ comment }: { comment: CanvasComment }) {
|
|
646
|
+
return (
|
|
647
|
+
<li className="comment-item">
|
|
648
|
+
<div className="comment-head">
|
|
649
|
+
<span className="comment-author mono">{comment.author}</span>
|
|
650
|
+
<span className="comment-time tabular-nums muted">
|
|
651
|
+
{formatRelative(comment.created)}
|
|
652
|
+
</span>
|
|
653
|
+
</div>
|
|
654
|
+
<div className="comment-text">{comment.text}</div>
|
|
655
|
+
{comment.thread && comment.thread.length > 0 && (
|
|
656
|
+
<div className="comment-thread">
|
|
657
|
+
{comment.thread.map((r, i) => (
|
|
658
|
+
<div key={i} className="comment-reply">
|
|
659
|
+
<span className="comment-author mono">{r.author}</span>
|
|
660
|
+
<span className="comment-reply-text">{r.text}</span>
|
|
661
|
+
</div>
|
|
662
|
+
))}
|
|
663
|
+
</div>
|
|
664
|
+
)}
|
|
665
|
+
</li>
|
|
666
|
+
);
|
|
667
|
+
}
|