@alt-t4b/pm-web 0.1.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/src/App.tsx ADDED
@@ -0,0 +1,829 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Card,
6
+ Icon,
7
+ IconButton,
8
+ Input,
9
+ Select,
10
+ Stack,
11
+ TopBar,
12
+ useTheme,
13
+ } from "./components";
14
+ import { API_BASE } from "./api";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface Project {
21
+ id: string;
22
+ slug: string;
23
+ name: string;
24
+ description: string;
25
+ status: "active" | "paused" | "completed" | "archived";
26
+ created_at: string;
27
+ updated_at: string;
28
+ }
29
+
30
+ interface Task {
31
+ id: string;
32
+ project_id: string;
33
+ title: string;
34
+ description: string;
35
+ status: "todo" | "in_progress" | "done";
36
+ created_at: string;
37
+ updated_at: string;
38
+ }
39
+
40
+ const statusOptions = [
41
+ { value: "active", label: "Active" },
42
+ { value: "paused", label: "Paused" },
43
+ { value: "completed", label: "Completed" },
44
+ { value: "archived", label: "Archived" },
45
+ ];
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Simple hash router
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function useHashRoute(): { path: string; navigate: (to: string) => void } {
52
+ const [path, setPath] = useState(() => window.location.hash.slice(1) || "/");
53
+
54
+ useEffect(() => {
55
+ function onHashChange() {
56
+ setPath(window.location.hash.slice(1) || "/");
57
+ }
58
+ window.addEventListener("hashchange", onHashChange);
59
+ return () => window.removeEventListener("hashchange", onHashChange);
60
+ }, []);
61
+
62
+ function navigate(to: string) {
63
+ window.location.hash = to;
64
+ }
65
+
66
+ return { path, navigate };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // App
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export function App() {
74
+ const { theme } = useTheme();
75
+ const { path, navigate } = useHashRoute();
76
+
77
+ // Match /projects/:slug
78
+ const projectSlugMatch = path.match(/^\/projects\/([^/]+)$/);
79
+ const projectSlug = projectSlugMatch?.[1] ?? null;
80
+
81
+ return (
82
+ <div style={{ display: "flex", flexDirection: "column", minHeight: "100vh", fontFamily: theme.font.body }}>
83
+ <TopBar />
84
+
85
+ <main style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", minWidth: 0 }}>
86
+ {projectSlug ? (
87
+ <ProjectView slug={projectSlug} onBack={() => navigate("/")} />
88
+ ) : (
89
+ <DashboardView onOpenProject={(slug) => navigate(`/projects/${slug}`)} />
90
+ )}
91
+ </main>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // DashboardView
98
+ // ---------------------------------------------------------------------------
99
+
100
+ function DashboardView({ onOpenProject }: { onOpenProject: (slug: string) => void }) {
101
+ const { theme } = useTheme();
102
+ const [projects, setProjects] = useState<Project[]>([]);
103
+ const [name, setName] = useState("");
104
+ const [slug, setSlug] = useState("");
105
+ const [description, setDescription] = useState("");
106
+ const [showCreateForm, setShowCreateForm] = useState(false);
107
+
108
+ async function fetchProjects() {
109
+ const res = await fetch(`${API_BASE}/api/projects`);
110
+ setProjects(await res.json());
111
+ }
112
+
113
+ useEffect(() => {
114
+ fetchProjects();
115
+ }, []);
116
+
117
+ async function handleCreate(e: React.FormEvent) {
118
+ e.preventDefault();
119
+ if (!name.trim()) return;
120
+ await fetch(`${API_BASE}/api/projects`, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ name, slug, description }),
124
+ });
125
+ setName("");
126
+ setSlug("");
127
+ setDescription("");
128
+ setShowCreateForm(false);
129
+ fetchProjects();
130
+ }
131
+
132
+ return (
133
+ <div
134
+ style={{
135
+ flex: 1,
136
+ width: "100%",
137
+ maxWidth: 1400,
138
+ padding: `${theme.spacing["2xl"]} ${theme.spacing.xl}`,
139
+ boxSizing: "border-box",
140
+ overflowY: "auto",
141
+ }}
142
+ >
143
+ {/* Page header */}
144
+ <Stack direction="row" justify="space-between" align="flex-end" wrap style={{ marginBottom: theme.spacing.xl, gap: theme.spacing.lg }}>
145
+ <div style={{ flex: "1 1 200px", minWidth: 0 }}>
146
+ <h2
147
+ style={{
148
+ margin: 0,
149
+ fontFamily: theme.font.headline,
150
+ fontSize: theme.font.size.xl,
151
+ fontWeight: 800,
152
+ letterSpacing: "-0.02em",
153
+ color: theme.color.text,
154
+ }}
155
+ >
156
+ Workspace Overview
157
+ </h2>
158
+ <p
159
+ style={{
160
+ margin: `${theme.spacing.xs} 0 0`,
161
+ color: theme.color.textMuted,
162
+ fontSize: theme.font.size.sm,
163
+ }}
164
+ >
165
+ Manage your active projects and track progress from a centralized view.
166
+ </p>
167
+ </div>
168
+ <Button onClick={() => setShowCreateForm(!showCreateForm)}>
169
+ <span style={{ display: "flex", alignItems: "center", gap: theme.spacing.sm }}>
170
+ <Icon name="add" size={16} />
171
+ New Project
172
+ </span>
173
+ </Button>
174
+ </Stack>
175
+
176
+ {/* Create form (toggled) */}
177
+ {showCreateForm && (
178
+ <Card variant="default" padding="lg" style={{ marginBottom: theme.spacing.xl }}>
179
+ <form onSubmit={handleCreate}>
180
+ <Stack direction="row" gap="sm" align="flex-end" wrap>
181
+ <div style={{ flex: "1 1 180px", minWidth: 0 }}>
182
+ <Input
183
+ label="Project Name"
184
+ placeholder="e.g. Riverfront Pavilion"
185
+ value={name}
186
+ onChange={(e) => setName(e.target.value)}
187
+ required
188
+ />
189
+ </div>
190
+ <div style={{ flex: "1 1 180px", minWidth: 0 }}>
191
+ <Input
192
+ label="Slug"
193
+ placeholder="e.g. riverfront-pavilion"
194
+ value={slug}
195
+ onChange={(e) => setSlug(e.target.value)}
196
+ required
197
+ />
198
+ </div>
199
+ <div style={{ flex: "1 1 180px", minWidth: 0 }}>
200
+ <Input
201
+ label="Description"
202
+ placeholder="Brief description (optional)"
203
+ value={description}
204
+ onChange={(e) => setDescription(e.target.value)}
205
+ />
206
+ </div>
207
+ <Stack direction="row" gap="sm">
208
+ <Button type="submit">Create</Button>
209
+ <Button variant="ghost" onClick={() => setShowCreateForm(false)} type="button">
210
+ Cancel
211
+ </Button>
212
+ </Stack>
213
+ </Stack>
214
+ </form>
215
+ </Card>
216
+ )}
217
+
218
+ {/* Project cards */}
219
+ <div
220
+ style={{
221
+ display: "flex",
222
+ flexWrap: "wrap",
223
+ gap: theme.spacing.lg,
224
+ }}
225
+ >
226
+ {projects.map((p) => (
227
+ <div
228
+ key={p.id}
229
+ style={{ flex: `1 1 calc(50% - ${theme.spacing.lg})`, maxWidth: "100%", minWidth: 280, cursor: "pointer" }}
230
+ onClick={() => onOpenProject(p.slug)}
231
+ >
232
+ <ProjectCard project={p} />
233
+ </div>
234
+ ))}
235
+ </div>
236
+
237
+ {/* Empty state */}
238
+ {projects.length === 0 && (
239
+ <Card variant="flat" padding="2xl">
240
+ <Stack align="center" gap="lg">
241
+ <Icon name="folder_open" size={40} style={{ color: theme.color.textFaint }} />
242
+ <p
243
+ style={{
244
+ margin: 0,
245
+ color: theme.color.textMuted,
246
+ fontSize: theme.font.size.sm,
247
+ }}
248
+ >
249
+ No projects yet. Click "New Project" to get started.
250
+ </p>
251
+ </Stack>
252
+ </Card>
253
+ )}
254
+ </div>
255
+ );
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // ProjectCard
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function ProjectCard({
263
+ project: p,
264
+ }: {
265
+ project: Project;
266
+ }) {
267
+ const { theme } = useTheme();
268
+ const isCompleted = p.status === "completed";
269
+ const date = new Date(p.updated_at).toLocaleDateString("en-US", {
270
+ month: "short",
271
+ day: "numeric",
272
+ });
273
+
274
+ return (
275
+ <Card
276
+ variant="default"
277
+ padding="lg"
278
+ style={{
279
+ display: "flex",
280
+ flexDirection: "column",
281
+ height: "100%",
282
+ boxSizing: "border-box",
283
+ borderLeft: isCompleted ? `3px solid ${theme.color.primary}` : undefined,
284
+ transition: "box-shadow 0.2s",
285
+ }}
286
+ >
287
+ {/* Badge */}
288
+ <div style={{ marginBottom: theme.spacing.md }}>
289
+ <Badge variant={p.status}>{p.status}</Badge>
290
+ </div>
291
+
292
+ {/* Title + description */}
293
+ <h3
294
+ style={{
295
+ margin: 0,
296
+ fontFamily: theme.font.headline,
297
+ fontSize: theme.font.size.lg,
298
+ fontWeight: 700,
299
+ color: theme.color.text,
300
+ marginBottom: theme.spacing.xs,
301
+ }}
302
+ >
303
+ {p.name}
304
+ </h3>
305
+ {p.description && (
306
+ <p
307
+ style={{
308
+ margin: `0 0 ${theme.spacing.lg}`,
309
+ fontSize: theme.font.size.xs,
310
+ color: theme.color.textMuted,
311
+ lineHeight: 1.5,
312
+ display: "-webkit-box",
313
+ WebkitLineClamp: 2,
314
+ WebkitBoxOrient: "vertical",
315
+ overflow: "hidden",
316
+ }}
317
+ >
318
+ {p.description}
319
+ </p>
320
+ )}
321
+
322
+ {/* Bottom: meta */}
323
+ <div style={{ marginTop: "auto", display: "flex", justifyContent: "flex-end" }}>
324
+ <span
325
+ style={{
326
+ display: "flex",
327
+ alignItems: "center",
328
+ gap: 4,
329
+ fontSize: "0.625rem",
330
+ fontWeight: 500,
331
+ color: theme.color.textFaint,
332
+ }}
333
+ >
334
+ <Icon name="calendar_today" size={12} />
335
+ {date}
336
+ </span>
337
+ </div>
338
+ </Card>
339
+ );
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // ProjectView — full project detail page reached via /projects/:slug
344
+ // ---------------------------------------------------------------------------
345
+
346
+ const taskStatusOptions = [
347
+ { value: "todo", label: "To Do" },
348
+ { value: "in_progress", label: "In Progress" },
349
+ { value: "done", label: "Done" },
350
+ ];
351
+
352
+ function ProjectView({ slug, onBack }: { slug: string; onBack: () => void }) {
353
+ const { theme } = useTheme();
354
+ const [project, setProject] = useState<Project | null>(null);
355
+ const [tasks, setTasks] = useState<Task[]>([]);
356
+ const [newTaskTitle, setNewTaskTitle] = useState("");
357
+ const [notFound, setNotFound] = useState(false);
358
+ const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
359
+
360
+ const selectedTask = selectedTaskId ? tasks.find((t) => t.id === selectedTaskId) ?? null : null;
361
+
362
+ async function fetchProject() {
363
+ const res = await fetch(`${API_BASE}/api/projects/${encodeURIComponent(slug)}`);
364
+ if (!res.ok) { setNotFound(true); return; }
365
+ const p: Project = await res.json();
366
+ setProject(p);
367
+ fetchTasks(p.slug);
368
+ }
369
+
370
+ async function fetchTasks(projectSlug: string) {
371
+ const res = await fetch(`${API_BASE}/api/projects/${projectSlug}/tasks`);
372
+ setTasks(await res.json());
373
+ }
374
+
375
+ useEffect(() => {
376
+ setNotFound(false);
377
+ setProject(null);
378
+ setTasks([]);
379
+ setSelectedTaskId(null);
380
+ fetchProject();
381
+ }, [slug]);
382
+
383
+ async function handleStatusChange(status: Project["status"]) {
384
+ if (!project) return;
385
+ await fetch(`${API_BASE}/api/projects/${project.slug}`, {
386
+ method: "PATCH",
387
+ headers: { "Content-Type": "application/json" },
388
+ body: JSON.stringify({ status }),
389
+ });
390
+ fetchProject();
391
+ }
392
+
393
+ async function handleAddTask(e: React.FormEvent) {
394
+ e.preventDefault();
395
+ if (!newTaskTitle.trim() || !project) return;
396
+ await fetch(`${API_BASE}/api/projects/${project.slug}/tasks`, {
397
+ method: "POST",
398
+ headers: { "Content-Type": "application/json" },
399
+ body: JSON.stringify({ title: newTaskTitle }),
400
+ });
401
+ setNewTaskTitle("");
402
+ fetchTasks(project.slug);
403
+ }
404
+
405
+ async function handleTaskStatusChange(taskId: string, status: Task["status"]) {
406
+ if (!project) return;
407
+ await fetch(`${API_BASE}/api/projects/${project.slug}/tasks/${taskId}`, {
408
+ method: "PATCH",
409
+ headers: { "Content-Type": "application/json" },
410
+ body: JSON.stringify({ status }),
411
+ });
412
+ fetchTasks(project.slug);
413
+ }
414
+
415
+ async function handleDeleteTask(taskId: string) {
416
+ if (!project) return;
417
+ await fetch(`${API_BASE}/api/projects/${project.slug}/tasks/${taskId}`, { method: "DELETE" });
418
+ fetchTasks(project.slug);
419
+ }
420
+
421
+ if (notFound) {
422
+ return (
423
+ <div style={{ flex: 1, width: "100%", maxWidth: 900, padding: `${theme.spacing["2xl"]} ${theme.spacing.xl}`, boxSizing: "border-box" }}>
424
+ <Button variant="ghost" onClick={onBack}>
425
+ <span style={{ display: "flex", alignItems: "center", gap: theme.spacing.xs }}>
426
+ <Icon name="arrow_back" size={16} /> Back
427
+ </span>
428
+ </Button>
429
+ <Card variant="flat" padding="2xl" style={{ marginTop: theme.spacing.xl }}>
430
+ <Stack align="center" gap="lg">
431
+ <Icon name="error_outline" size={40} style={{ color: theme.color.textFaint }} />
432
+ <p style={{ margin: 0, color: theme.color.textMuted, fontSize: theme.font.size.sm }}>
433
+ Project not found.
434
+ </p>
435
+ </Stack>
436
+ </Card>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ if (!project) {
442
+ return (
443
+ <div style={{ flex: 1, width: "100%", maxWidth: 900, padding: `${theme.spacing["2xl"]} ${theme.spacing.xl}`, boxSizing: "border-box" }}>
444
+ <p style={{ color: theme.color.textMuted, fontSize: theme.font.size.sm }}>Loading...</p>
445
+ </div>
446
+ );
447
+ }
448
+
449
+ const todo = tasks.filter((t) => t.status === "todo");
450
+ const inProgress = tasks.filter((t) => t.status === "in_progress");
451
+ const done = tasks.filter((t) => t.status === "done");
452
+
453
+ return (
454
+ <div
455
+ style={{
456
+ flex: 1,
457
+ width: "100%",
458
+ maxWidth: selectedTask ? 1800 : 900,
459
+ display: "flex",
460
+ gap: theme.spacing.xl,
461
+ boxSizing: "border-box",
462
+ transition: "max-width 0.25s ease",
463
+ }}
464
+ >
465
+ <div style={{ flex: 1, minWidth: 0, padding: `${theme.spacing["2xl"]} ${theme.spacing.xl}`, boxSizing: "border-box", overflowY: "auto" }}>
466
+ {/* Back button */}
467
+ <Button variant="ghost" onClick={onBack} style={{ marginBottom: theme.spacing.lg }}>
468
+ <span style={{ display: "flex", alignItems: "center", gap: theme.spacing.xs }}>
469
+ <Icon name="arrow_back" size={16} /> All Projects
470
+ </span>
471
+ </Button>
472
+
473
+ {/* Project header */}
474
+ <Stack direction="row" justify="space-between" align="flex-start" wrap style={{ gap: theme.spacing.lg, marginBottom: theme.spacing.xl }}>
475
+ <div style={{ flex: 1, minWidth: 0 }}>
476
+ <Stack direction="row" align="center" gap="sm" style={{ marginBottom: theme.spacing.sm }}>
477
+ <Badge variant={project.status}>{project.status}</Badge>
478
+ </Stack>
479
+ <h2
480
+ style={{
481
+ margin: 0,
482
+ fontFamily: theme.font.headline,
483
+ fontSize: theme.font.size.xl,
484
+ fontWeight: 800,
485
+ letterSpacing: "-0.02em",
486
+ color: theme.color.text,
487
+ }}
488
+ >
489
+ {project.name}
490
+ </h2>
491
+ {project.description && (
492
+ <p style={{ margin: `${theme.spacing.sm} 0 0`, color: theme.color.textMuted, fontSize: theme.font.size.sm, lineHeight: 1.5 }}>
493
+ {project.description}
494
+ </p>
495
+ )}
496
+ </div>
497
+ <Select
498
+ value={project.status}
499
+ onChange={(e) => handleStatusChange(e.target.value as Project["status"])}
500
+ options={statusOptions}
501
+ />
502
+ </Stack>
503
+
504
+ {/* Tasks */}
505
+ <Stack direction="row" justify="space-between" align="center" style={{ marginBottom: theme.spacing.md }}>
506
+ <h3
507
+ style={{
508
+ margin: 0,
509
+ fontFamily: theme.font.headline,
510
+ fontSize: theme.font.size.lg,
511
+ fontWeight: 700,
512
+ color: theme.color.text,
513
+ }}
514
+ >
515
+ Tasks
516
+ </h3>
517
+ <span style={{ fontSize: theme.font.size.xs, color: theme.color.textFaint }}>
518
+ {tasks.length} task{tasks.length !== 1 ? "s" : ""}
519
+ </span>
520
+ </Stack>
521
+
522
+ {/* Add task */}
523
+ <form onSubmit={handleAddTask} style={{ marginBottom: theme.spacing.xl }}>
524
+ <Stack direction="row" gap="xs" align="flex-end">
525
+ <div style={{ flex: 1, minWidth: 0 }}>
526
+ <Input
527
+ placeholder="Add a task..."
528
+ value={newTaskTitle}
529
+ onChange={(e) => setNewTaskTitle(e.target.value)}
530
+ />
531
+ </div>
532
+ <Button type="submit" size="sm">
533
+ <Icon name="add" size={16} />
534
+ </Button>
535
+ </Stack>
536
+ </form>
537
+
538
+ {/* Task groups */}
539
+ <Stack gap="lg">
540
+ {[
541
+ { label: "To Do", items: todo, icon: "radio_button_unchecked" },
542
+ { label: "In Progress", items: inProgress, icon: "pending" },
543
+ { label: "Done", items: done, icon: "check_circle" },
544
+ ].map(
545
+ (group) =>
546
+ group.items.length > 0 && (
547
+ <div key={group.label}>
548
+ <Stack direction="row" align="center" gap="xs" style={{ marginBottom: theme.spacing.sm }}>
549
+ <Icon name={group.icon} size={14} style={{ color: theme.color.textFaint }} />
550
+ <span
551
+ style={{
552
+ fontSize: "0.625rem",
553
+ fontWeight: 700,
554
+ letterSpacing: "0.08em",
555
+ textTransform: "uppercase",
556
+ color: theme.color.textFaint,
557
+ }}
558
+ >
559
+ {group.label} ({group.items.length})
560
+ </span>
561
+ </Stack>
562
+ <Stack gap="xs">
563
+ {group.items.map((task) => (
564
+ <div
565
+ key={task.id}
566
+ onClick={() => setSelectedTaskId(task.id)}
567
+ style={{
568
+ background: theme.color.surfaceContainer,
569
+ borderRadius: theme.radius.lg,
570
+ padding: `${theme.spacing.sm} ${theme.spacing.md}`,
571
+ cursor: "pointer",
572
+ transition: "background 0.15s",
573
+ }}
574
+ onMouseEnter={(e) => (e.currentTarget.style.background = theme.color.surfaceContainerHigh)}
575
+ onMouseLeave={(e) => (e.currentTarget.style.background = theme.color.surfaceContainer)}
576
+ >
577
+ <Stack direction="row" justify="space-between" align="center" gap="xs">
578
+ <span
579
+ style={{
580
+ flex: 1,
581
+ minWidth: 0,
582
+ fontSize: theme.font.size.sm,
583
+ color: task.status === "done" ? theme.color.textFaint : theme.color.text,
584
+ textDecoration: task.status === "done" ? "line-through" : undefined,
585
+ overflow: "hidden",
586
+ textOverflow: "ellipsis",
587
+ whiteSpace: "nowrap",
588
+ }}
589
+ >
590
+ {task.title}
591
+ </span>
592
+ <Stack direction="row" gap="xs" style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
593
+ <Select
594
+ value={task.status}
595
+ onChange={(e) => handleTaskStatusChange(task.id, e.target.value as Task["status"])}
596
+ options={taskStatusOptions}
597
+ style={{ fontSize: "0.625rem", padding: "0.1rem 0.25rem" }}
598
+ />
599
+ <IconButton
600
+ icon="close"
601
+ size={12}
602
+ onClick={() => handleDeleteTask(task.id)}
603
+ style={{ color: theme.color.textFaint, width: 18, height: 18 }}
604
+ />
605
+ </Stack>
606
+ </Stack>
607
+ </div>
608
+ ))}
609
+ </Stack>
610
+ </div>
611
+ )
612
+ )}
613
+ {tasks.length === 0 && (
614
+ <p style={{ margin: 0, fontSize: theme.font.size.sm, color: theme.color.textFaint, textAlign: "center" }}>
615
+ No tasks yet. Add one above.
616
+ </p>
617
+ )}
618
+ </Stack>
619
+ </div>
620
+
621
+ {/* Task detail panel */}
622
+ {selectedTask && (
623
+ <TaskDetailPanel
624
+ task={selectedTask}
625
+ onClose={() => setSelectedTaskId(null)}
626
+ />
627
+ )}
628
+ </div>
629
+ );
630
+ }
631
+
632
+ // ---------------------------------------------------------------------------
633
+ // TaskDetailPanel — slide-over detail view for a single task
634
+ // ---------------------------------------------------------------------------
635
+
636
+
637
+ const statusLabel: Record<Task["status"], string> = {
638
+ todo: "To Do",
639
+ in_progress: "In Progress",
640
+ done: "Done",
641
+ };
642
+
643
+ function useWindowWidth() {
644
+ const [width, setWidth] = useState(window.innerWidth);
645
+ useEffect(() => {
646
+ const onResize = () => setWidth(window.innerWidth);
647
+ window.addEventListener("resize", onResize);
648
+ return () => window.removeEventListener("resize", onResize);
649
+ }, []);
650
+ return width;
651
+ }
652
+
653
+ const SMALL_BREAKPOINT = 768;
654
+
655
+ function TaskDetailPanel({ task, onClose }: { task: Task; onClose: () => void }) {
656
+ const { theme } = useTheme();
657
+ const windowWidth = useWindowWidth();
658
+ const isSmall = windowWidth < SMALL_BREAKPOINT;
659
+
660
+ const formatDate = (iso: string) =>
661
+ new Date(iso).toLocaleDateString("en-US", {
662
+ month: "short",
663
+ day: "numeric",
664
+ year: "numeric",
665
+ hour: "numeric",
666
+ minute: "2-digit",
667
+ });
668
+
669
+ const panel = (
670
+ <div
671
+ style={{
672
+ ...(isSmall
673
+ ? { position: "fixed" as const, inset: 0, zIndex: 101 }
674
+ : { flex: "1 0 400px", maxWidth: 640, alignSelf: "stretch" }),
675
+ borderLeft: isSmall ? undefined : `1px solid ${theme.color.borderSubtle}`,
676
+ background: isSmall ? theme.color.surface : theme.color.surfaceContainerLow,
677
+ display: "flex",
678
+ flexDirection: "column" as const,
679
+ overflow: "hidden",
680
+ }}
681
+ >
682
+ {/* Header */}
683
+ <div
684
+ style={{
685
+ padding: `${theme.spacing.xl} ${theme.spacing.xl} ${theme.spacing.lg}`,
686
+ borderBottom: `1px solid ${theme.color.borderSubtle}`,
687
+ }}
688
+ >
689
+ <Stack direction="row" justify="space-between" align="flex-start" gap="sm">
690
+ <div style={{ flex: 1, minWidth: 0 }}>
691
+ {/* Status indicator */}
692
+ <Stack direction="row" align="center" gap="xs" style={{ marginBottom: theme.spacing.sm }}>
693
+ <span
694
+ style={{
695
+ display: "inline-block",
696
+ width: 8,
697
+ height: 8,
698
+ borderRadius: theme.radius.full,
699
+ background: task.status === "done" ? theme.color.success : task.status === "in_progress" ? theme.color.tertiary : theme.color.textFaint,
700
+ }}
701
+ />
702
+ <span
703
+ style={{
704
+ fontSize: theme.font.size.xs,
705
+ color: theme.color.textMuted,
706
+ fontWeight: 500,
707
+ }}
708
+ >
709
+ {statusLabel[task.status]}
710
+ </span>
711
+ </Stack>
712
+
713
+ {/* Title */}
714
+ <h2
715
+ style={{
716
+ margin: 0,
717
+ fontFamily: theme.font.headline,
718
+ fontSize: theme.font.size.xl,
719
+ fontWeight: 800,
720
+ letterSpacing: "-0.02em",
721
+ color: theme.color.text,
722
+ lineHeight: 1.3,
723
+ }}
724
+ >
725
+ {task.title}
726
+ </h2>
727
+ </div>
728
+ <IconButton icon="close" size={18} onClick={onClose} />
729
+ </Stack>
730
+ </div>
731
+
732
+ {/* Body */}
733
+ <div
734
+ style={{
735
+ flex: 1,
736
+ overflowY: "auto",
737
+ padding: theme.spacing.xl,
738
+ display: "flex",
739
+ flexDirection: "column",
740
+ }}
741
+ >
742
+ {/* Description */}
743
+ {task.description ? (
744
+ <p
745
+ style={{
746
+ margin: 0,
747
+ fontFamily: theme.font.body,
748
+ fontSize: theme.font.size.md,
749
+ color: theme.color.text,
750
+ lineHeight: 1.75,
751
+ whiteSpace: "pre-wrap",
752
+ }}
753
+ >
754
+ {task.description}
755
+ </p>
756
+ ) : (
757
+ <p
758
+ style={{
759
+ margin: 0,
760
+ fontSize: theme.font.size.sm,
761
+ color: theme.color.textFaint,
762
+ fontStyle: "italic",
763
+ }}
764
+ >
765
+ No description
766
+ </p>
767
+ )}
768
+
769
+ {/* Metadata */}
770
+ <div
771
+ style={{
772
+ marginTop: "auto",
773
+ paddingTop: theme.spacing.xl,
774
+ borderTop: `1px solid ${theme.color.borderSubtle}`,
775
+ }}
776
+ >
777
+ <span
778
+ style={{
779
+ display: "block",
780
+ fontSize: "0.625rem",
781
+ fontWeight: 700,
782
+ letterSpacing: "0.08em",
783
+ textTransform: "uppercase",
784
+ color: theme.color.textFaint,
785
+ marginBottom: theme.spacing.sm,
786
+ }}
787
+ >
788
+ Metadata
789
+ </span>
790
+ <Stack gap="xs">
791
+ {[
792
+ { label: "ID", value: task.id },
793
+ { label: "Created", value: formatDate(task.created_at) },
794
+ { label: "Updated", value: formatDate(task.updated_at) },
795
+ ].map((row) => (
796
+ <Stack key={row.label} direction="row" justify="space-between" align="center">
797
+ <span style={{ fontSize: theme.font.size.xs, color: theme.color.textFaint }}>{row.label}</span>
798
+ <span
799
+ style={{
800
+ fontSize: theme.font.size.xs,
801
+ color: theme.color.textMuted,
802
+ fontFamily: "monospace",
803
+ textAlign: "right",
804
+ }}
805
+ >
806
+ {row.value}
807
+ </span>
808
+ </Stack>
809
+ ))}
810
+ </Stack>
811
+ </div>
812
+ </div>
813
+ </div>
814
+ );
815
+
816
+ if (isSmall) {
817
+ return (
818
+ <>
819
+ <div
820
+ onClick={onClose}
821
+ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 100 }}
822
+ />
823
+ {panel}
824
+ </>
825
+ );
826
+ }
827
+
828
+ return panel;
829
+ }