@donotdev/cli 0.0.20 → 0.0.21

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 (103) hide show
  1. package/README.md +31 -0
  2. package/dependencies-matrix.json +86 -19
  3. package/dist/bin/commands/agent-setup.js +2 -2
  4. package/dist/bin/commands/build.js +6 -6
  5. package/dist/bin/commands/bump.js +491 -69
  6. package/dist/bin/commands/cacheout.js +6 -6
  7. package/dist/bin/commands/coach.js +6 -6
  8. package/dist/bin/commands/create-app.js +23 -15
  9. package/dist/bin/commands/create-project.js +101 -16
  10. package/dist/bin/commands/db.js +142136 -0
  11. package/dist/bin/commands/deploy.js +336 -126
  12. package/dist/bin/commands/dev.js +6 -6
  13. package/dist/bin/commands/doctor.js +140 -33
  14. package/dist/bin/commands/emu.js +6 -6
  15. package/dist/bin/commands/format.js +6 -6
  16. package/dist/bin/commands/get-demo.js +11 -6
  17. package/dist/bin/commands/make-admin.js +14210 -13770
  18. package/dist/bin/commands/preview.js +6 -6
  19. package/dist/bin/commands/seed.js +142426 -0
  20. package/dist/bin/commands/setup-cicd.js +8904 -0
  21. package/dist/bin/commands/setup.js +256 -212
  22. package/dist/bin/commands/staging.js +343 -127
  23. package/dist/bin/commands/sync-secrets.js +55 -33
  24. package/dist/bin/commands/type-check.js +6 -6
  25. package/dist/bin/commands/wai.js +6 -6
  26. package/dist/bin/dndev.js +76 -11
  27. package/dist/bin/donotdev.js +21 -12
  28. package/dist/index.js +437 -142
  29. package/package.json +1 -1
  30. package/templates/app-demo/.env.example +1 -0
  31. package/templates/{root-consumer → app-demo}/entities/ExampleEntity.ts.example +15 -9
  32. package/templates/app-demo/index.html.example +1 -1
  33. package/templates/app-dndev/index.html.example +164 -0
  34. package/templates/app-dndev/public/logo.svg.example +1 -0
  35. package/templates/app-dndev/public/manifest.json.example +10 -0
  36. package/templates/app-dndev/src/App.tsx.example +35 -0
  37. package/templates/app-dndev/src/components/CockpitLayout.css.example +181 -0
  38. package/templates/app-dndev/src/components/CockpitLayout.tsx.example +209 -0
  39. package/templates/app-dndev/src/components/Kanban.css.example +385 -0
  40. package/templates/app-dndev/src/components/ModeToggle.tsx.example +32 -0
  41. package/templates/app-dndev/src/components/OverlaySlot.tsx.example +68 -0
  42. package/templates/app-dndev/src/components/TerminalPanel.css.example +228 -0
  43. package/templates/app-dndev/src/components/TerminalPanel.tsx.example +714 -0
  44. package/templates/app-dndev/src/components/markdown-prose.css.example +49 -0
  45. package/templates/app-dndev/src/components/phases/CaptainLog.tsx.example +107 -0
  46. package/templates/app-dndev/src/components/phases/ContextTabs.tsx.example +352 -0
  47. package/templates/app-dndev/src/components/phases/PhaseCard.tsx.example +126 -0
  48. package/templates/app-dndev/src/components/phases/PhaseDetail.tsx.example +147 -0
  49. package/templates/app-dndev/src/components/phases/ReviewPanel.tsx.example +115 -0
  50. package/templates/app-dndev/src/components/phases/phaseData.ts.example +366 -0
  51. package/templates/app-dndev/src/config/app.ts.example +103 -0
  52. package/templates/app-dndev/src/config/commands.ts.example +171 -0
  53. package/templates/app-dndev/src/config/legal.ts.example +170 -0
  54. package/templates/app-dndev/src/config/providers.ts.example +7 -0
  55. package/templates/app-dndev/src/globals.css.example +10 -0
  56. package/templates/app-dndev/src/hooks/useDndevFile.ts.example +144 -0
  57. package/templates/app-dndev/src/main.tsx.example +21 -0
  58. package/templates/app-dndev/src/pages/BoardPage.tsx.example +640 -0
  59. package/templates/app-dndev/src/pages/GrillPage.tsx.example +658 -0
  60. package/templates/app-dndev/src/pages/HomePage.tsx.example +347 -0
  61. package/templates/app-dndev/src/pages/NotFoundPage.tsx.example +33 -0
  62. package/templates/app-dndev/src/pages/PhasesPage.tsx.example +137 -0
  63. package/templates/app-dndev/src/pages/SettingsPage.tsx.example +64 -0
  64. package/templates/app-dndev/src/pages/legal/LegalNoticePage.tsx.example +75 -0
  65. package/templates/app-dndev/src/pages/legal/PrivacyPage.tsx.example +69 -0
  66. package/templates/app-dndev/src/pages/legal/TermsPage.tsx.example +71 -0
  67. package/templates/app-dndev/src/stores/dndevStore.ts.example +386 -0
  68. package/templates/app-dndev/src/themes.css.example +161 -0
  69. package/templates/app-dndev/terminal-sidecar.cjs.example +341 -0
  70. package/templates/app-dndev/tsconfig.json.example +9 -0
  71. package/templates/app-dndev/vite.config.ts.example +24 -0
  72. package/templates/app-next/src/locales/home_en.json.example +6 -6
  73. package/templates/app-vite/index.html.example +1 -1
  74. package/templates/app-vite/src/locales/home_en.json.example +6 -6
  75. package/templates/functions-supabase/supabase/functions/.env.example +0 -2
  76. package/templates/root-consumer/.claude/commands/grill.md.example +86 -8
  77. package/templates/root-consumer/.dndev.secrets.example +32 -0
  78. package/templates/root-consumer/.gitignore.example +3 -0
  79. package/templates/root-consumer/AI.md.example +4 -0
  80. package/templates/root-consumer/entities/index.ts.example +2 -5
  81. package/templates/root-consumer/guides/dndev/COMPONENTS_ATOMIC.md.example +4 -0
  82. package/templates/root-consumer/guides/dndev/ENV_SETUP.md.example +23 -20
  83. package/templates/root-consumer/guides/dndev/INDEX.md.example +1 -0
  84. package/templates/root-consumer/guides/dndev/SETUP_BILLING.md.example +3 -7
  85. package/templates/root-consumer/guides/dndev/SETUP_CICD.md.example +115 -0
  86. package/templates/root-consumer/guides/dndev/SETUP_CRUD.md.example +41 -0
  87. package/templates/root-consumer/guides/dndev/SETUP_SUPABASE.md.example +13 -18
  88. package/templates/root-consumer/guides/dndev/SETUP_VERCEL.md.example +17 -12
  89. package/templates/root-consumer/guides/dndev/advanced/COOKIE_REFERENCE.md.example +252 -252
  90. package/templates/root-consumer/guides/dndev/advanced/VERSION_CONTROL.md.example +174 -174
  91. package/templates/root-consumer/guides/wai-way/WAI_WAY_CLI.md.example +185 -251
  92. package/templates/root-consumer/guides/wai-way/agents/extractor.md.example +26 -8
  93. package/templates/root-consumer/guides/wai-way/blueprints/0_brainstorm.md.example +66 -49
  94. package/templates/root-consumer/guides/wai-way/blueprints/1_scaffold.md.example +6 -5
  95. package/templates/root-consumer/guides/wai-way/blueprints/2_entities.md.example +9 -9
  96. package/templates/root-consumer/guides/wai-way/blueprints/3_compose.md.example +1 -1
  97. package/templates/root-consumer/guides/wai-way/blueprints/4_configure.md.example +7 -6
  98. package/templates/root-consumer/guides/wai-way/context_map.json.example +51 -20
  99. package/templates/root-consumer/guides/wai-way/hld_template.md.example +138 -0
  100. package/templates/root-consumer/guides/wai-way/lld_template.md.example +103 -0
  101. package/templates/root-consumer/guides/wai-way/prd_template.md.example +140 -0
  102. /package/templates/{root-consumer → app-demo}/entities/Contact.ts.example +0 -0
  103. /package/templates/{root-consumer → app-demo}/entities/demo.ts.example +0 -0
@@ -0,0 +1,640 @@
1
+ /**
2
+ * @fileoverview Board — tickets (tasks, bugs, ideas, improvements) with Kanban columns
3
+ *
4
+ * Replaces KanbanPage + absorbs ReportPage.
5
+ * Click "+" to create ticket. Bug type includes screenshot capture.
6
+ * "Send to Agent" injects task into terminal.
7
+ */
8
+
9
+ import { useEffect, useRef, useState } from 'react';
10
+ import {
11
+ LayoutList,
12
+ Plus,
13
+ GripVertical,
14
+ Save,
15
+ Send,
16
+ Trash2,
17
+ ChevronDown,
18
+ Bug,
19
+ Lightbulb,
20
+ ArrowUpCircle,
21
+ CheckSquare,
22
+ ImagePlus,
23
+ X,
24
+ Clipboard,
25
+ } from 'lucide-react';
26
+ import {
27
+ DndContext,
28
+ closestCorners,
29
+ PointerSensor,
30
+ TouchSensor,
31
+ useSensor,
32
+ useSensors,
33
+ DragOverlay,
34
+ } from '@dnd-kit/core';
35
+ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
36
+ import { useDroppable } from '@dnd-kit/core';
37
+ import { CSS } from '@dnd-kit/utilities';
38
+
39
+ import { Badge, Stack, Text, Button, Sheet, Input, Textarea, ToggleGroup, FloatingLabel } from '@donotdev/components';
40
+ import type { PageMeta } from '@donotdev/core';
41
+ import { PageContainer } from '@donotdev/ui';
42
+
43
+ import { OverlaySlotPortal } from '../components/OverlaySlot';
44
+ import { useDoNotDashStore, COLUMNS } from '../stores/dndevStore';
45
+
46
+ import type { KanbanCard, CardColumn, CardPriority, TicketType } from '../stores/dndevStore';
47
+ import type { DragEndEvent, DragStartEvent, DragOverEvent } from '@dnd-kit/core';
48
+ import type { LucideIcon } from 'lucide-react';
49
+
50
+ export const meta: PageMeta = {
51
+ icon: <LayoutList />,
52
+ title: 'Board',
53
+ };
54
+
55
+ // ============================================================================
56
+ // CONSTANTS
57
+ // ============================================================================
58
+
59
+ const VISIBLE_CAP = 20;
60
+
61
+ const PRIORITY_COLOR: Record<string, string> = {
62
+ critical: '#ef4444',
63
+ high: '#f97316',
64
+ medium: '#eab308',
65
+ low: '#6b7280',
66
+ };
67
+
68
+ const PRIORITY_ITEMS = [
69
+ { value: 'low', label: 'Low' },
70
+ { value: 'medium', label: 'Medium' },
71
+ { value: 'high', label: 'High' },
72
+ { value: 'critical', label: 'Critical' },
73
+ ];
74
+
75
+ const COLUMN_ITEMS = COLUMNS.map((col) => ({ value: col.id, label: col.label }));
76
+
77
+ const TYPE_ICON: Record<TicketType, LucideIcon> = {
78
+ task: CheckSquare,
79
+ bug: Bug,
80
+ idea: Lightbulb,
81
+ improvement: ArrowUpCircle,
82
+ };
83
+
84
+ const TYPE_ITEMS: { value: string; label: string }[] = [
85
+ { value: 'task', label: 'Task' },
86
+ { value: 'bug', label: 'Bug' },
87
+ { value: 'idea', label: 'Idea' },
88
+ { value: 'improvement', label: 'Improvement' },
89
+ ];
90
+
91
+ const FILTER_ITEMS = [
92
+ { value: 'all', label: 'All' },
93
+ ...TYPE_ITEMS,
94
+ ];
95
+
96
+ // ============================================================================
97
+ // SORTABLE CARD
98
+ // ============================================================================
99
+
100
+ function SortableCard({
101
+ card,
102
+ onOpen,
103
+ }: {
104
+ card: KanbanCard;
105
+ onOpen: (card: KanbanCard) => void;
106
+ }) {
107
+ const {
108
+ attributes,
109
+ listeners,
110
+ setNodeRef,
111
+ transform,
112
+ transition,
113
+ isDragging,
114
+ } = useSortable({ id: card.id, data: { column: card.column } });
115
+
116
+ const style = {
117
+ transform: CSS.Transform.toString(transform),
118
+ transition,
119
+ opacity: isDragging ? 0.4 : 1,
120
+ };
121
+
122
+ const TypeIcon = TYPE_ICON[card.type ?? 'task'];
123
+
124
+ return (
125
+ <div
126
+ ref={setNodeRef}
127
+ style={style}
128
+ className="dndev-kanban-card"
129
+ data-priority={card.priority}
130
+ onClick={() => onOpen(card)}
131
+ onKeyDown={(e) => { if (e.key === 'Enter') onOpen(card); }}
132
+ {...attributes}
133
+ {...listeners}
134
+ >
135
+ <div className="dndev-kanban-card-inner" style={{ borderInlineStartColor: PRIORITY_COLOR[card.priority] }}>
136
+ <GripVertical size={12} className="dndev-kanban-grip-icon" />
137
+
138
+ <div className="dndev-kanban-card-body">
139
+ <div className="dndev-kanban-card-title">
140
+ <TypeIcon size={12} style={{ flexShrink: 0, opacity: 0.6 }} />
141
+ {' '}{card.title}
142
+ </div>
143
+ {card.filePath && (
144
+ <div className="dndev-kanban-card-meta">
145
+ {card.filePath}{card.lineNumber ? `:${card.lineNumber}` : ''}
146
+ </div>
147
+ )}
148
+ </div>
149
+
150
+ <Badge
151
+ variant={card.priority === 'critical' ? 'destructive' : card.priority === 'high' ? 'warning' : 'muted'}
152
+ >
153
+ {card.priority}
154
+ </Badge>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ // ============================================================================
161
+ // DROPPABLE COLUMN
162
+ // ============================================================================
163
+
164
+ function KanbanColumn({
165
+ column,
166
+ cards,
167
+ onAdd,
168
+ onOpenCard,
169
+ }: {
170
+ column: typeof COLUMNS[number];
171
+ cards: KanbanCard[];
172
+ onAdd: (column: CardColumn) => void;
173
+ onOpenCard: (card: KanbanCard) => void;
174
+ }) {
175
+ const [expanded, setExpanded] = useState(false);
176
+ const capped = !expanded && cards.length > VISIBLE_CAP;
177
+ const visibleCards = capped ? cards.slice(0, VISIBLE_CAP) : cards;
178
+ const hiddenCount = cards.length - visibleCards.length;
179
+
180
+ const { setNodeRef: setDroppableRef } = useDroppable({
181
+ id: `column-${column.id}`,
182
+ data: { column: column.id },
183
+ });
184
+
185
+ return (
186
+ <div className="dndev-kanban-column" data-column={column.id}>
187
+ <div className="dndev-kanban-col-header">
188
+ <div className="dndev-kanban-col-title">
189
+ <span className="dndev-kanban-col-dot" />
190
+ <Text level="caption" weight="semibold">{column.label}</Text>
191
+ <Badge variant="muted">{cards.length}</Badge>
192
+ </div>
193
+ <button
194
+ type="button"
195
+ className="dndev-kanban-add"
196
+ onClick={() => onAdd(column.id)}
197
+ aria-label={`Add card to ${column.label}`}
198
+ >
199
+ <Plus size={14} />
200
+ </button>
201
+ </div>
202
+
203
+ <SortableContext items={visibleCards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
204
+ <div ref={setDroppableRef} className="dndev-kanban-cards" data-column={column.id}>
205
+ {visibleCards.map((card) => (
206
+ <SortableCard key={card.id} card={card} onOpen={onOpenCard} />
207
+ ))}
208
+ {cards.length === 0 && (
209
+ <div className="dndev-kanban-empty">
210
+ <Text level="small" variant="muted">Drop cards here</Text>
211
+ </div>
212
+ )}
213
+ </div>
214
+ </SortableContext>
215
+
216
+ {capped && (
217
+ <button
218
+ type="button"
219
+ className="dndev-kanban-show-more"
220
+ onClick={() => setExpanded(true)}
221
+ >
222
+ <ChevronDown size={14} />
223
+ <span>Show {hiddenCount} more</span>
224
+ </button>
225
+ )}
226
+ {expanded && cards.length > VISIBLE_CAP && (
227
+ <button
228
+ type="button"
229
+ className="dndev-kanban-show-more"
230
+ onClick={() => setExpanded(false)}
231
+ >
232
+ <span>Collapse</span>
233
+ </button>
234
+ )}
235
+ </div>
236
+ );
237
+ }
238
+
239
+ // ============================================================================
240
+ // CARD DETAIL SHEET (with ticket type + bug fields)
241
+ // ============================================================================
242
+
243
+ function CardSheet({
244
+ cardId,
245
+ onClose,
246
+ }: {
247
+ cardId: string;
248
+ onClose: () => void;
249
+ }) {
250
+ const card = useDoNotDashStore((s) => s.cards.find((c) => c.id === cardId));
251
+ const fileInputRef = useRef<HTMLInputElement>(null);
252
+
253
+ const [form, setForm] = useState(() => ({
254
+ title: card?.title ?? '',
255
+ description: card?.description ?? '',
256
+ filePath: card?.filePath ?? '',
257
+ priority: card?.priority ?? 'medium',
258
+ column: card?.column ?? 'backlog',
259
+ type: (card?.type ?? 'task') as TicketType,
260
+ route: card?.route ?? '',
261
+ screenshot: card?.screenshot ?? '',
262
+ }));
263
+
264
+ if (!card) return null;
265
+ const c = card;
266
+
267
+ const patch = (key: string, value: string) => setForm((f) => ({ ...f, [key]: value }));
268
+
269
+ function handleScreenshot(dataUrl: string) {
270
+ setForm((f) => ({ ...f, screenshot: dataUrl }));
271
+ }
272
+
273
+ function handlePaste(e: React.ClipboardEvent) {
274
+ for (const item of e.clipboardData.items) {
275
+ if (item.type.startsWith('image/')) {
276
+ e.preventDefault();
277
+ const blob = item.getAsFile();
278
+ if (!blob) continue;
279
+ const reader = new FileReader();
280
+ reader.onload = () => handleScreenshot(reader.result as string);
281
+ reader.readAsDataURL(blob);
282
+ return;
283
+ }
284
+ }
285
+ }
286
+
287
+ function handleDrop(e: React.DragEvent) {
288
+ e.preventDefault();
289
+ const file = e.dataTransfer.files[0];
290
+ if (!file || !file.type.startsWith('image/')) return;
291
+ const reader = new FileReader();
292
+ reader.onload = () => handleScreenshot(reader.result as string);
293
+ reader.readAsDataURL(file);
294
+ }
295
+
296
+ function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
297
+ const file = e.target.files?.[0];
298
+ if (!file || !file.type.startsWith('image/')) return;
299
+ const reader = new FileReader();
300
+ reader.onload = () => handleScreenshot(reader.result as string);
301
+ reader.readAsDataURL(file);
302
+ }
303
+
304
+ async function save() {
305
+ // Save screenshot server-side if it's a data URL
306
+ let screenshotId = form.screenshot;
307
+ if (form.screenshot.startsWith('data:')) {
308
+ screenshotId = c.id;
309
+ const base64 = form.screenshot.replace(/^data:image\/\w+;base64,/, '');
310
+ try {
311
+ await fetch('/api/dndev/bugs/screenshot', {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify({ id: screenshotId, data: base64 }),
315
+ });
316
+ } catch { /* silent */ }
317
+ }
318
+
319
+ useDoNotDashStore.getState().updateCard(cardId, {
320
+ title: form.title,
321
+ description: form.description || undefined,
322
+ priority: form.priority as CardPriority,
323
+ column: form.column as CardColumn,
324
+ type: form.type,
325
+ filePath: form.filePath || undefined,
326
+ route: form.route || undefined,
327
+ screenshot: screenshotId || undefined,
328
+ });
329
+ onClose();
330
+ }
331
+
332
+ function handleDelete() {
333
+ useDoNotDashStore.getState().removeCard(c.id);
334
+ onClose();
335
+ }
336
+
337
+ function handleSendToAgent() {
338
+ const prompt = [
339
+ `${form.type === 'bug' ? 'Bug report' : 'Task'} (${c.priority}): ${c.title}`,
340
+ c.description ? `Description: ${c.description}` : '',
341
+ c.filePath ? `File: ${c.filePath}${c.lineNumber ? `:${c.lineNumber}` : ''}` : '',
342
+ c.route ? `Route: ${c.route}` : '',
343
+ c.screenshot ? `Screenshot: .dndev/bugs/${c.screenshot}.png` : '',
344
+ form.type === 'bug' ? 'Please investigate and fix this bug.' : 'Please investigate and implement this.',
345
+ ].filter(Boolean).join('\n');
346
+
347
+ useDoNotDashStore.getState().injectPrompt(prompt, { mode: 'ai-agent' });
348
+ useDoNotDashStore.getState().moveCard(c.id, 'in_progress');
349
+ onClose();
350
+ }
351
+
352
+ return (
353
+ <Sheet
354
+ open
355
+ onOpenChange={(open) => { if (!open) onClose(); }}
356
+ title="Edit Ticket"
357
+ side="right"
358
+ footer={
359
+ <Stack direction="row" align="center" gap="tight">
360
+ <Button variant="destructive" icon={Trash2} onClick={handleDelete}>
361
+ Delete
362
+ </Button>
363
+ <Stack style={{ flex: 1 }}>{' '}</Stack>
364
+ <Button variant="ghost" icon={Send} onClick={handleSendToAgent}>
365
+ Send to Agent
366
+ </Button>
367
+ <Button variant="default" icon={Save} onClick={save}>
368
+ Save
369
+ </Button>
370
+ </Stack>
371
+ }
372
+ >
373
+ <div onPaste={handlePaste}>
374
+ <Stack gap="tight">
375
+ {/* Type selector */}
376
+ <Stack gap="tight">
377
+ <Text level="small" weight="medium">Type</Text>
378
+ <ToggleGroup
379
+ type="single"
380
+ size="sm"
381
+ value={form.type}
382
+ onValueChange={(v) => { if (v) patch('type', v); }}
383
+ items={TYPE_ITEMS}
384
+ />
385
+ </Stack>
386
+
387
+ <Input
388
+ label="Title"
389
+ value={form.title}
390
+ onChange={(e) => patch('title', e.target.value)}
391
+ autoFocus
392
+ />
393
+
394
+ <FloatingLabel label="Description">
395
+ <Textarea
396
+ value={form.description}
397
+ onChange={(e) => patch('description', e.target.value)}
398
+ rows={4}
399
+ placeholder="What needs to be done..."
400
+ />
401
+ </FloatingLabel>
402
+
403
+ <Input
404
+ label="File Path"
405
+ value={form.filePath}
406
+ onChange={(e) => patch('filePath', e.target.value)}
407
+ placeholder="src/components/Foo.tsx"
408
+ />
409
+
410
+ {/* Bug-specific fields */}
411
+ {form.type === 'bug' && (
412
+ <>
413
+ <Input
414
+ label="Route / URL"
415
+ value={form.route}
416
+ onChange={(e) => patch('route', e.target.value)}
417
+ placeholder="/dashboard/settings"
418
+ />
419
+
420
+ {/* Screenshot capture */}
421
+ <Stack gap="tight">
422
+ <Text level="small" weight="medium">Screenshot</Text>
423
+ {form.screenshot ? (
424
+ <Stack style={{ position: 'relative' }}>
425
+ <img
426
+ src={form.screenshot.startsWith('data:')
427
+ ? form.screenshot
428
+ : `/api/dndev/file?path=.dndev/bugs/${form.screenshot}.png`}
429
+ alt="Bug screenshot"
430
+ style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px', objectFit: 'contain' }}
431
+ />
432
+ <Button
433
+ variant="destructive"
434
+ display="compact"
435
+ icon={X}
436
+ onClick={() => patch('screenshot', '')}
437
+ aria-label="Remove screenshot"
438
+ style={{ position: 'absolute', top: '4px', insetInlineEnd: '4px' }}
439
+ />
440
+ </Stack>
441
+ ) : (
442
+ <Stack
443
+ align="center"
444
+ gap="tight"
445
+ style={{
446
+ padding: '24px',
447
+ border: '1px dashed var(--border)',
448
+ borderRadius: '8px',
449
+ cursor: 'pointer',
450
+ }}
451
+ onClick={() => fileInputRef.current?.click()}
452
+ onDrop={handleDrop}
453
+ onDragOver={(e: React.DragEvent) => e.preventDefault()}
454
+ >
455
+ <ImagePlus size={24} />
456
+ <Text level="small" variant="muted">Paste, drop, or click to upload</Text>
457
+ <Badge variant="muted"><Clipboard size={12} /> Clipboard ready</Badge>
458
+ </Stack>
459
+ )}
460
+ <input
461
+ ref={fileInputRef}
462
+ type="file"
463
+ accept="image/*"
464
+ onChange={handleFileChange}
465
+ style={{ display: 'none' }}
466
+ />
467
+ </Stack>
468
+ </>
469
+ )}
470
+
471
+ <Stack gap="tight">
472
+ <Text level="small" weight="medium">Priority</Text>
473
+ <ToggleGroup
474
+ type="single"
475
+ value={form.priority}
476
+ onValueChange={(v) => { if (v) patch('priority', v); }}
477
+ items={PRIORITY_ITEMS}
478
+ />
479
+ </Stack>
480
+
481
+ <Stack gap="tight">
482
+ <Text level="small" weight="medium">Column</Text>
483
+ <ToggleGroup
484
+ type="single"
485
+ value={form.column}
486
+ onValueChange={(v) => { if (v) patch('column', v); }}
487
+ items={COLUMN_ITEMS}
488
+ />
489
+ </Stack>
490
+
491
+ <Text level="caption" variant="muted">
492
+ Created {new Date(c.createdAt).toLocaleString()}
493
+ </Text>
494
+ </Stack>
495
+ </div>
496
+ </Sheet>
497
+ );
498
+ }
499
+
500
+ // ============================================================================
501
+ // BOARD
502
+ // ============================================================================
503
+
504
+ export default function BoardPage() {
505
+ const cards = useDoNotDashStore((s) => s.cards);
506
+ const [activeId, setActiveId] = useState<string | null>(null);
507
+ const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
508
+ const [typeFilter, setTypeFilter] = useState<string>('all');
509
+
510
+ const sensors = useSensors(
511
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
512
+ useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
513
+ );
514
+
515
+ const filteredCards = typeFilter === 'all'
516
+ ? cards
517
+ : cards.filter((c) => (c.type ?? 'task') === typeFilter);
518
+
519
+ const cardsByColumn: Record<CardColumn, KanbanCard[]> = {
520
+ backlog: [], in_progress: [], review: [], done: [],
521
+ };
522
+ for (const card of filteredCards) {
523
+ cardsByColumn[card.column]?.push(card);
524
+ }
525
+
526
+ const activeCard = cards.find((c) => c.id === activeId) ?? null;
527
+
528
+ useEffect(() => {
529
+ if (import.meta.hot && !import.meta.hot.data.kanbanHmr) {
530
+ import.meta.hot.data.kanbanHmr = true;
531
+ import.meta.hot.on('dndev:file-changed', (data: { path: string }) => {
532
+ if (data.path === '.dndev/kanban.json') {
533
+ fetch('/api/dndev/file?path=.dndev/kanban.json')
534
+ .then((r) => r.json())
535
+ .then((json) => {
536
+ if (Array.isArray(json.content)) useDoNotDashStore.getState().loadCards(json.content);
537
+ })
538
+ .catch(() => {});
539
+ }
540
+ });
541
+ }
542
+ }, []);
543
+
544
+ function handleDragStart(event: DragStartEvent) {
545
+ setActiveId(String(event.active.id));
546
+ }
547
+
548
+ /** Extract column from an over target — works for both sortable cards and droppable columns */
549
+ function resolveColumn(over: DragOverEvent['over']): CardColumn | null {
550
+ if (!over) return null;
551
+ const data = over.data.current as { column?: CardColumn } | undefined;
552
+ return data?.column ?? null;
553
+ }
554
+
555
+ function handleDragOver(event: DragOverEvent) {
556
+ const { active, over } = event;
557
+ const targetColumn = resolveColumn(over);
558
+ if (!targetColumn) return;
559
+ const activeData = active.data.current as { column?: CardColumn } | undefined;
560
+ if (activeData?.column && activeData.column !== targetColumn) {
561
+ // Optimistic move in store (no disk write) so dnd-kit SortableContexts stay in sync
562
+ useDoNotDashStore.getState().moveCard(String(active.id), targetColumn, false);
563
+ }
564
+ }
565
+
566
+ function handleDragEnd(event: DragEndEvent) {
567
+ setActiveId(null);
568
+ const { active, over } = event;
569
+ // Resolve target column — or fall back to the card's current column (set by handleDragOver)
570
+ const targetColumn = resolveColumn(over)
571
+ ?? useDoNotDashStore.getState().cards.find((c) => c.id === String(active.id))?.column;
572
+ if (targetColumn) {
573
+ // Persist final position to disk
574
+ useDoNotDashStore.getState().moveCard(String(active.id), targetColumn);
575
+ }
576
+ }
577
+
578
+ function handleAdd(column: CardColumn) {
579
+ useDoNotDashStore.getState().addCard({ title: 'New ticket', column, type: 'task' });
580
+ }
581
+
582
+ return (
583
+ <PageContainer>
584
+ <OverlaySlotPortal>
585
+ <Stack direction="row" align="center" gap="tight">
586
+ <ToggleGroup
587
+ type="single"
588
+ size="sm"
589
+ variant="outline"
590
+ value={typeFilter}
591
+ onValueChange={(v) => { if (v) setTypeFilter(v); }}
592
+ items={FILTER_ITEMS}
593
+ />
594
+ <Text level="caption" variant="muted">
595
+ {filteredCards.length}
596
+ </Text>
597
+ </Stack>
598
+ </OverlaySlotPortal>
599
+
600
+ <DndContext
601
+ sensors={sensors}
602
+ collisionDetection={closestCorners}
603
+ onDragStart={handleDragStart}
604
+ onDragOver={handleDragOver}
605
+ onDragEnd={handleDragEnd}
606
+ >
607
+ <div className="dndev-kanban-board">
608
+ {COLUMNS.map((col) => (
609
+ <KanbanColumn
610
+ key={col.id}
611
+ column={col}
612
+ cards={cardsByColumn[col.id]}
613
+ onAdd={handleAdd}
614
+ onOpenCard={(card) => setSelectedCardId(card.id)}
615
+ />
616
+ ))}
617
+ </div>
618
+
619
+ <DragOverlay>
620
+ {activeCard ? (
621
+ <div className="dndev-kanban-card-overlay">
622
+ <div
623
+ className="dndev-kanban-card-inner"
624
+ style={{ borderInlineStartColor: PRIORITY_COLOR[activeCard.priority] }}
625
+ >
626
+ <div className="dndev-kanban-card-body">
627
+ <div className="dndev-kanban-card-title">{activeCard.title}</div>
628
+ </div>
629
+ </div>
630
+ </div>
631
+ ) : null}
632
+ </DragOverlay>
633
+ </DndContext>
634
+
635
+ {selectedCardId && (
636
+ <CardSheet cardId={selectedCardId} onClose={() => setSelectedCardId(null)} />
637
+ )}
638
+ </PageContainer>
639
+ );
640
+ }