create-project-arch 1.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/README.md +58 -0
- package/dist/cli.js +232 -0
- package/dist/cli.test.js +8 -0
- package/package.json +29 -0
- package/templates/arch-ui/.arch/edges/decision_to_domain.json +4 -0
- package/templates/arch-ui/.arch/edges/milestone_to_task.json +4 -0
- package/templates/arch-ui/.arch/edges/task_to_decision.json +4 -0
- package/templates/arch-ui/.arch/edges/task_to_module.json +4 -0
- package/templates/arch-ui/.arch/graph.json +17 -0
- package/templates/arch-ui/.arch/nodes/decisions.json +4 -0
- package/templates/arch-ui/.arch/nodes/domains.json +4 -0
- package/templates/arch-ui/.arch/nodes/milestones.json +4 -0
- package/templates/arch-ui/.arch/nodes/modules.json +4 -0
- package/templates/arch-ui/.arch/nodes/tasks.json +4 -0
- package/templates/arch-ui/app/api/architecture/map/route.ts +13 -0
- package/templates/arch-ui/app/api/decisions/route.ts +23 -0
- package/templates/arch-ui/app/api/domain-docs/route.ts +89 -0
- package/templates/arch-ui/app/api/domains/route.ts +10 -0
- package/templates/arch-ui/app/api/graph/route.ts +16 -0
- package/templates/arch-ui/app/api/health/route.ts +44 -0
- package/templates/arch-ui/app/api/node-files/route.ts +173 -0
- package/templates/arch-ui/app/api/phases/route.ts +10 -0
- package/templates/arch-ui/app/api/route.ts +22 -0
- package/templates/arch-ui/app/api/search/route.ts +56 -0
- package/templates/arch-ui/app/api/task-doc/[taskId]/route.ts +60 -0
- package/templates/arch-ui/app/api/tasks/route.ts +36 -0
- package/templates/arch-ui/app/api/trace/file/route.ts +40 -0
- package/templates/arch-ui/app/api/trace/task/[taskId]/route.ts +12 -0
- package/templates/arch-ui/app/architecture/page.tsx +5 -0
- package/templates/arch-ui/app/globals.css +240 -0
- package/templates/arch-ui/app/health/page.tsx +48 -0
- package/templates/arch-ui/app/layout.tsx +19 -0
- package/templates/arch-ui/app/page.tsx +5 -0
- package/templates/arch-ui/app/work/page.tsx +265 -0
- package/templates/arch-ui/components/app-shell.tsx +171 -0
- package/templates/arch-ui/components/error-boundary.tsx +53 -0
- package/templates/arch-ui/components/graph/arch-node.tsx +77 -0
- package/templates/arch-ui/components/graph/build-graph-from-dataset.ts +196 -0
- package/templates/arch-ui/components/graph/build-initial-graph.ts +245 -0
- package/templates/arch-ui/components/graph/graph-context-menu.tsx +84 -0
- package/templates/arch-ui/components/graph/graph-doc-panel.tsx +46 -0
- package/templates/arch-ui/components/graph/graph-types.ts +82 -0
- package/templates/arch-ui/components/graph/use-auto-layout.ts +65 -0
- package/templates/arch-ui/components/graph/use-connection-validation.ts +62 -0
- package/templates/arch-ui/components/graph/use-flow-persistence.ts +48 -0
- package/templates/arch-ui/components/graph-canvas.tsx +670 -0
- package/templates/arch-ui/components/health-panel.tsx +49 -0
- package/templates/arch-ui/components/inspector-context.tsx +35 -0
- package/templates/arch-ui/components/inspector.tsx +895 -0
- package/templates/arch-ui/components/markdown-viewer.tsx +74 -0
- package/templates/arch-ui/components/sidebar.tsx +531 -0
- package/templates/arch-ui/components/topbar.tsx +187 -0
- package/templates/arch-ui/components/work-table.tsx +57 -0
- package/templates/arch-ui/components/workspace-context.tsx +274 -0
- package/templates/arch-ui/eslint.config.js +2 -0
- package/templates/arch-ui/global.d.ts +1 -0
- package/templates/arch-ui/lib/api.ts +93 -0
- package/templates/arch-ui/lib/arch-model.ts +113 -0
- package/templates/arch-ui/lib/graph-dataset.ts +756 -0
- package/templates/arch-ui/lib/graph-schema.ts +408 -0
- package/templates/arch-ui/lib/project-root.ts +52 -0
- package/templates/arch-ui/lib/types.ts +116 -0
- package/templates/arch-ui/next-env.d.ts +6 -0
- package/templates/arch-ui/next.config.js +17 -0
- package/templates/arch-ui/package.json +38 -0
- package/templates/arch-ui/postcss.config.mjs +6 -0
- package/templates/arch-ui/tailwind.config.ts +11 -0
- package/templates/arch-ui/tsconfig.json +21 -0
- package/templates/ui-package/eslint.config.mjs +4 -0
- package/templates/ui-package/package.json +26 -0
- package/templates/ui-package/src/accordion.tsx +10 -0
- package/templates/ui-package/src/badge.tsx +12 -0
- package/templates/ui-package/src/button.tsx +32 -0
- package/templates/ui-package/src/card.tsx +22 -0
- package/templates/ui-package/src/code.tsx +6 -0
- package/templates/ui-package/src/command.tsx +18 -0
- package/templates/ui-package/src/dialog.tsx +6 -0
- package/templates/ui-package/src/dropdown-menu.tsx +10 -0
- package/templates/ui-package/src/input.tsx +6 -0
- package/templates/ui-package/src/navigation-menu.tsx +6 -0
- package/templates/ui-package/src/scroll-area.tsx +6 -0
- package/templates/ui-package/src/select.tsx +6 -0
- package/templates/ui-package/src/separator.tsx +6 -0
- package/templates/ui-package/src/sheet.tsx +6 -0
- package/templates/ui-package/src/skeleton.tsx +6 -0
- package/templates/ui-package/src/table.tsx +26 -0
- package/templates/ui-package/src/tabs.tsx +14 -0
- package/templates/ui-package/src/toggle-group.tsx +10 -0
- package/templates/ui-package/src/utils.ts +3 -0
- package/templates/ui-package/tsconfig.json +10 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { HealthPanel } from "../../components/health-panel";
|
|
6
|
+
import { useInspector } from "../../components/inspector-context";
|
|
7
|
+
import { getHealth } from "../../lib/api";
|
|
8
|
+
import { CheckData } from "../../lib/types";
|
|
9
|
+
|
|
10
|
+
export default function HealthPage() {
|
|
11
|
+
const [data, setData] = useState<CheckData | null>(null);
|
|
12
|
+
const { setSelection } = useInspector();
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
void getHealth().then(setData);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="grid gap-3">
|
|
20
|
+
<Card>
|
|
21
|
+
<CardHeader>
|
|
22
|
+
<CardTitle>Architecture Health</CardTitle>
|
|
23
|
+
</CardHeader>
|
|
24
|
+
<CardContent>
|
|
25
|
+
{data ? (
|
|
26
|
+
<HealthPanel
|
|
27
|
+
health={data}
|
|
28
|
+
onInspect={(label, status) =>
|
|
29
|
+
setSelection({
|
|
30
|
+
type: "health",
|
|
31
|
+
title: label,
|
|
32
|
+
metadata: [
|
|
33
|
+
{ label: "Status", value: status },
|
|
34
|
+
{ label: "Errors", value: String(data.errors.length) },
|
|
35
|
+
{ label: "Warnings", value: String(data.warnings.length) },
|
|
36
|
+
],
|
|
37
|
+
links: [...data.errors, ...data.warnings],
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
/>
|
|
41
|
+
) : (
|
|
42
|
+
<p>Loading health checks...</p>
|
|
43
|
+
)}
|
|
44
|
+
</CardContent>
|
|
45
|
+
</Card>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
import { AppShell } from "../components/app-shell";
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
title: "Architecture Control Surface",
|
|
7
|
+
description: "Architecture graph and traceability UI",
|
|
8
|
+
};
|
|
9
|
+
export const dynamic = "force-dynamic";
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<html lang="en">
|
|
14
|
+
<body className="h-dvh overflow-hidden">
|
|
15
|
+
<AppShell>{children}</AppShell>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@repo/ui/card";
|
|
4
|
+
import { Input } from "@repo/ui/input";
|
|
5
|
+
import { Select } from "@repo/ui/select";
|
|
6
|
+
import { Tabs, TabsList, TabsTrigger } from "@repo/ui/tabs";
|
|
7
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
8
|
+
import { useEffect, useMemo, useState } from "react";
|
|
9
|
+
import { GraphCanvas } from "../../components/graph-canvas";
|
|
10
|
+
import { GraphEdgeFilter } from "../../components/graph/graph-types";
|
|
11
|
+
import { useInspector } from "../../components/inspector-context";
|
|
12
|
+
import { WorkTable } from "../../components/work-table";
|
|
13
|
+
import { useWorkspace } from "../../components/workspace-context";
|
|
14
|
+
import { getGraphDataset, getPhases, getTasks } from "../../lib/api";
|
|
15
|
+
import { GraphDatasetResponse, GraphValidationData, TaskNode } from "../../lib/types";
|
|
16
|
+
|
|
17
|
+
const defaultFilters = ["domains", "modules", "tasks", "decisions"] as Array<
|
|
18
|
+
"domains" | "modules" | "tasks" | "decisions"
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export default function WorkPage() {
|
|
22
|
+
const [phases, setPhases] = useState<Array<{ id: string; active: boolean }>>([]);
|
|
23
|
+
const [tasks, setTasks] = useState<TaskNode[]>([]);
|
|
24
|
+
const [graphData, setGraphData] = useState<GraphDatasetResponse["dataset"] | null>(null);
|
|
25
|
+
const [graphValidation, setGraphValidation] = useState<GraphValidationData | null>(null);
|
|
26
|
+
const [loadErrors, setLoadErrors] = useState<string[]>([]);
|
|
27
|
+
const [phase, setPhase] = useState("all");
|
|
28
|
+
const [query, setQuery] = useState("");
|
|
29
|
+
const { setSelection } = useInspector();
|
|
30
|
+
const { filters, setHopDepth } = useWorkspace();
|
|
31
|
+
const searchParams = useSearchParams();
|
|
32
|
+
const router = useRouter();
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
|
|
37
|
+
void Promise.allSettled([getPhases(), getTasks(), getGraphDataset()]).then((results) => {
|
|
38
|
+
if (cancelled) return;
|
|
39
|
+
|
|
40
|
+
const [phaseResult, taskResult, graphResult] = results;
|
|
41
|
+
const errors: string[] = [];
|
|
42
|
+
|
|
43
|
+
if (phaseResult.status === "fulfilled") {
|
|
44
|
+
setPhases(phaseResult.value);
|
|
45
|
+
} else {
|
|
46
|
+
setPhases([]);
|
|
47
|
+
errors.push(`Phases unavailable: ${phaseResult.reason instanceof Error ? phaseResult.reason.message : "Unknown error"}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (taskResult.status === "fulfilled") {
|
|
51
|
+
setTasks(taskResult.value.tasks);
|
|
52
|
+
} else {
|
|
53
|
+
setTasks([]);
|
|
54
|
+
errors.push(`Tasks unavailable: ${taskResult.reason instanceof Error ? taskResult.reason.message : "Unknown error"}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (graphResult.status === "fulfilled") {
|
|
58
|
+
setGraphData(graphResult.value.dataset);
|
|
59
|
+
setGraphValidation(graphResult.value.validation);
|
|
60
|
+
if (!graphResult.value.validation.valid) {
|
|
61
|
+
errors.push(
|
|
62
|
+
...graphResult.value.validation.errors.map((issue) => `Graph schema error: ${issue.message}`),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
setGraphData(null);
|
|
67
|
+
setGraphValidation(null);
|
|
68
|
+
errors.push(
|
|
69
|
+
`Graph unavailable: ${
|
|
70
|
+
graphResult.reason instanceof Error ? graphResult.reason.message : "Unknown error"
|
|
71
|
+
}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setLoadErrors(errors);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const viewParam = searchParams.get("view");
|
|
84
|
+
const view = viewParam === "project" ? "project" : viewParam === "architecture" ? "architecture" : "tasks";
|
|
85
|
+
|
|
86
|
+
const milestones = useMemo(() => {
|
|
87
|
+
const milestoneSet = new Set<string>();
|
|
88
|
+
for (const task of tasks) {
|
|
89
|
+
if (phase !== "all" && !task.milestone.startsWith(`${phase}/`)) continue;
|
|
90
|
+
milestoneSet.add(task.milestone);
|
|
91
|
+
}
|
|
92
|
+
return [...milestoneSet];
|
|
93
|
+
}, [tasks, phase]);
|
|
94
|
+
|
|
95
|
+
const filteredTasks = useMemo(() => {
|
|
96
|
+
return tasks.filter((task) => {
|
|
97
|
+
const inPhase = phase === "all" || task.milestone.startsWith(`${phase}/`);
|
|
98
|
+
if (!inPhase) return false;
|
|
99
|
+
if (!query) return true;
|
|
100
|
+
const q = query.toLowerCase();
|
|
101
|
+
return task.id.toLowerCase().includes(q) || task.title.toLowerCase().includes(q);
|
|
102
|
+
});
|
|
103
|
+
}, [phase, query, tasks]);
|
|
104
|
+
|
|
105
|
+
const activeFilters = useMemo(
|
|
106
|
+
() => defaultFilters.filter((filter) => filters.nodeTypes[filter]),
|
|
107
|
+
[filters.nodeTypes],
|
|
108
|
+
);
|
|
109
|
+
const activeEdgeFilters = useMemo(
|
|
110
|
+
() =>
|
|
111
|
+
(Object.entries(filters.edgeTypes) as Array<[keyof typeof filters.edgeTypes, boolean]>)
|
|
112
|
+
.filter(([, enabled]) => enabled)
|
|
113
|
+
.map(([filter]) => filter as GraphEdgeFilter),
|
|
114
|
+
[filters.edgeTypes],
|
|
115
|
+
);
|
|
116
|
+
const activeAuthorityFilters = useMemo(
|
|
117
|
+
() =>
|
|
118
|
+
(Object.entries(filters.authorityTypes) as Array<
|
|
119
|
+
[keyof typeof filters.authorityTypes, boolean]
|
|
120
|
+
>)
|
|
121
|
+
.filter(([, enabled]) => enabled)
|
|
122
|
+
.map(([filter]) => filter),
|
|
123
|
+
[filters.authorityTypes],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="grid gap-3">
|
|
128
|
+
{loadErrors.length > 0 ? (
|
|
129
|
+
<div className="rounded-xl border border-amber-700 bg-amber-950/60 p-3 text-sm text-amber-200">
|
|
130
|
+
{loadErrors.map((message) => (
|
|
131
|
+
<p key={message}>{message}</p>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
<Card>
|
|
136
|
+
<CardHeader>
|
|
137
|
+
<CardTitle>Work Views</CardTitle>
|
|
138
|
+
</CardHeader>
|
|
139
|
+
<CardContent>
|
|
140
|
+
<Tabs>
|
|
141
|
+
<TabsList>
|
|
142
|
+
<TabsTrigger
|
|
143
|
+
className={view === "architecture" ? "border border-blue-700 bg-slate-800 text-slate-100" : ""}
|
|
144
|
+
onClick={() => router.push("/work?view=architecture")}
|
|
145
|
+
>
|
|
146
|
+
Architecture
|
|
147
|
+
</TabsTrigger>
|
|
148
|
+
<TabsTrigger
|
|
149
|
+
className={view === "tasks" ? "border border-blue-700 bg-slate-800 text-slate-100" : ""}
|
|
150
|
+
onClick={() => router.push("/work?view=tasks")}
|
|
151
|
+
>
|
|
152
|
+
Tasks
|
|
153
|
+
</TabsTrigger>
|
|
154
|
+
<TabsTrigger
|
|
155
|
+
className={view === "project" ? "border border-blue-700 bg-slate-800 text-slate-100" : ""}
|
|
156
|
+
onClick={() => router.push("/work?view=project")}
|
|
157
|
+
>
|
|
158
|
+
Project
|
|
159
|
+
</TabsTrigger>
|
|
160
|
+
</TabsList>
|
|
161
|
+
</Tabs>
|
|
162
|
+
</CardContent>
|
|
163
|
+
</Card>
|
|
164
|
+
|
|
165
|
+
{graphValidation && graphValidation.errors.length > 0 ? (
|
|
166
|
+
<div className="rounded-xl border border-red-700 bg-red-950/60 p-3 text-sm text-red-200">
|
|
167
|
+
<p className="font-medium">Graph activation blocked by schema validation errors.</p>
|
|
168
|
+
{graphValidation.errors.map((issue, index) => (
|
|
169
|
+
<p key={`${issue.ruleId}:${index}`}>[{issue.ruleId}] {issue.message}</p>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
) : graphData ? (
|
|
173
|
+
<GraphCanvas
|
|
174
|
+
data={graphData}
|
|
175
|
+
viewMode={view === "project" ? "project" : view === "architecture" ? "architecture-map" : "tasks"}
|
|
176
|
+
enabledFilters={activeFilters}
|
|
177
|
+
enabledEdgeFilters={activeEdgeFilters}
|
|
178
|
+
enabledAuthorityFilters={activeAuthorityFilters}
|
|
179
|
+
hopDepth={filters.hopDepth}
|
|
180
|
+
onHopDepthChange={setHopDepth}
|
|
181
|
+
showExternalDependencies={filters.showExternalDependencies}
|
|
182
|
+
hideCompletedTasks={filters.hideCompletedTasks}
|
|
183
|
+
onNodeSelect={(node) =>
|
|
184
|
+
setSelection({
|
|
185
|
+
type: node.type as "domain" | "decision" | "phase" | "milestone" | "task" | "file" | "health",
|
|
186
|
+
title: node.title,
|
|
187
|
+
id: node.id,
|
|
188
|
+
metadata: node.metadata,
|
|
189
|
+
markdown: node.markdown,
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
/>
|
|
193
|
+
) : (
|
|
194
|
+
<div className="rounded-xl border border-slate-700 bg-slate-900/90 p-3">
|
|
195
|
+
Loading graph...
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
{graphValidation && graphValidation.warnings.length > 0 ? (
|
|
199
|
+
<div className="rounded-xl border border-amber-700 bg-amber-950/60 p-3 text-sm text-amber-200">
|
|
200
|
+
<p className="font-medium">Graph warnings</p>
|
|
201
|
+
{graphValidation.warnings.map((issue, index) => (
|
|
202
|
+
<p key={`${issue.ruleId}:${index}`}>[{issue.ruleId}] {issue.message}</p>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
) : null}
|
|
206
|
+
|
|
207
|
+
{view !== "architecture" ? (
|
|
208
|
+
<Card>
|
|
209
|
+
<CardHeader>
|
|
210
|
+
<CardTitle>Roadmap Work</CardTitle>
|
|
211
|
+
</CardHeader>
|
|
212
|
+
<CardContent className="grid gap-2">
|
|
213
|
+
<div className="flex flex-wrap gap-2">
|
|
214
|
+
<Select value={phase} onChange={(event) => setPhase(event.target.value)}>
|
|
215
|
+
<option value="all">All phases</option>
|
|
216
|
+
{phases.map((item) => (
|
|
217
|
+
<option key={item.id} value={item.id}>
|
|
218
|
+
{item.id} {item.active ? "(active)" : ""}
|
|
219
|
+
</option>
|
|
220
|
+
))}
|
|
221
|
+
</Select>
|
|
222
|
+
<Input
|
|
223
|
+
placeholder="Search task..."
|
|
224
|
+
value={query}
|
|
225
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="grid gap-1 text-slate-400">
|
|
230
|
+
{milestones.map((milestone) => (
|
|
231
|
+
<span key={milestone}>{milestone}</span>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
</CardContent>
|
|
235
|
+
</Card>
|
|
236
|
+
) : null}
|
|
237
|
+
|
|
238
|
+
{view !== "architecture" ? (
|
|
239
|
+
<Card>
|
|
240
|
+
<CardHeader>
|
|
241
|
+
<CardTitle>Tasks</CardTitle>
|
|
242
|
+
</CardHeader>
|
|
243
|
+
<CardContent>
|
|
244
|
+
<WorkTable
|
|
245
|
+
tasks={filteredTasks}
|
|
246
|
+
onSelectTask={(task) =>
|
|
247
|
+
setSelection({
|
|
248
|
+
type: "task",
|
|
249
|
+
title: task.title,
|
|
250
|
+
id: task.id,
|
|
251
|
+
metadata: [
|
|
252
|
+
{ label: "Phase", value: task.milestone.split("/")[0] ?? "unknown" },
|
|
253
|
+
{ label: "Milestone", value: task.milestone },
|
|
254
|
+
{ label: "Status", value: task.lane },
|
|
255
|
+
{ label: "Domain", value: task.domain ?? "foundation" },
|
|
256
|
+
],
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
/>
|
|
260
|
+
</CardContent>
|
|
261
|
+
</Card>
|
|
262
|
+
) : null}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MouseEvent as ReactMouseEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { ErrorBoundary } from "./error-boundary";
|
|
5
|
+
import { InspectorPanel } from "./inspector";
|
|
6
|
+
import { InspectorProvider } from "./inspector-context";
|
|
7
|
+
import { Sidebar } from "./sidebar";
|
|
8
|
+
import { Topbar } from "./topbar";
|
|
9
|
+
import { useWorkspace, WorkspaceProvider } from "./workspace-context";
|
|
10
|
+
|
|
11
|
+
function AppShellContent({ children }: { children: ReactNode }) {
|
|
12
|
+
const collapsedLeftWidth = 56;
|
|
13
|
+
const collapsedRightWidth = 120;
|
|
14
|
+
const minMainWidth = 420;
|
|
15
|
+
const minExpandedLeftWidth = 220;
|
|
16
|
+
const minExpandedRightWidth = 320;
|
|
17
|
+
const resizerWidth = 4;
|
|
18
|
+
const {
|
|
19
|
+
leftCollapsed,
|
|
20
|
+
rightCollapsed,
|
|
21
|
+
leftWidth,
|
|
22
|
+
rightWidth,
|
|
23
|
+
setLeftCollapsed,
|
|
24
|
+
setRightCollapsed,
|
|
25
|
+
setLeftWidth,
|
|
26
|
+
setRightWidth,
|
|
27
|
+
resetLayout,
|
|
28
|
+
} = useWorkspace();
|
|
29
|
+
const [viewportWidth, setViewportWidth] = useState(0);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (typeof window === "undefined") return;
|
|
33
|
+
const update = () => setViewportWidth(window.innerWidth);
|
|
34
|
+
update();
|
|
35
|
+
window.addEventListener("resize", update);
|
|
36
|
+
return () => window.removeEventListener("resize", update);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
41
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
42
|
+
const key = event.key.toLowerCase();
|
|
43
|
+
if (key === "b") {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setLeftCollapsed(!leftCollapsed);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key === "i") {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
setRightCollapsed(!rightCollapsed);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key === "0") {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
resetLayout();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
window.addEventListener("keydown", onKeyDown);
|
|
59
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
60
|
+
}, [
|
|
61
|
+
leftCollapsed,
|
|
62
|
+
resetLayout,
|
|
63
|
+
rightCollapsed,
|
|
64
|
+
setLeftCollapsed,
|
|
65
|
+
setRightCollapsed,
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const shellStyle = useMemo(() => {
|
|
69
|
+
const leftResizer = leftCollapsed ? 0 : resizerWidth;
|
|
70
|
+
const rightResizer = rightCollapsed ? 0 : resizerWidth;
|
|
71
|
+
let effectiveLeft = leftCollapsed ? collapsedLeftWidth : leftWidth;
|
|
72
|
+
let effectiveRight = rightCollapsed ? collapsedRightWidth : rightWidth;
|
|
73
|
+
|
|
74
|
+
if (viewportWidth > 0) {
|
|
75
|
+
const maxSideBudget = Math.max(0, viewportWidth - minMainWidth - leftResizer - rightResizer);
|
|
76
|
+
let overflow = effectiveLeft + effectiveRight - maxSideBudget;
|
|
77
|
+
|
|
78
|
+
if (overflow > 0 && !rightCollapsed) {
|
|
79
|
+
const reducible = Math.max(0, effectiveRight - minExpandedRightWidth);
|
|
80
|
+
const reduction = Math.min(reducible, overflow);
|
|
81
|
+
effectiveRight -= reduction;
|
|
82
|
+
overflow -= reduction;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (overflow > 0 && !leftCollapsed) {
|
|
86
|
+
const reducible = Math.max(0, effectiveLeft - minExpandedLeftWidth);
|
|
87
|
+
const reduction = Math.min(reducible, overflow);
|
|
88
|
+
effectiveLeft -= reduction;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
gridTemplateColumns: `${effectiveLeft}px ${leftResizer}px minmax(0, 1fr) ${rightResizer}px ${effectiveRight}px`,
|
|
94
|
+
};
|
|
95
|
+
}, [leftCollapsed, leftWidth, rightCollapsed, rightWidth, viewportWidth]);
|
|
96
|
+
|
|
97
|
+
function startResize(side: "left" | "right", event: ReactMouseEvent<HTMLDivElement>) {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
const startX = event.clientX;
|
|
100
|
+
const startWidth = side === "left" ? leftWidth : rightWidth;
|
|
101
|
+
function onMove(moveEvent: MouseEvent) {
|
|
102
|
+
const delta = moveEvent.clientX - startX;
|
|
103
|
+
if (side === "left") {
|
|
104
|
+
setLeftWidth(startWidth + delta);
|
|
105
|
+
} else {
|
|
106
|
+
setRightWidth(startWidth - delta);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function onUp() {
|
|
110
|
+
window.removeEventListener("mousemove", onMove);
|
|
111
|
+
window.removeEventListener("mouseup", onUp);
|
|
112
|
+
}
|
|
113
|
+
window.addEventListener("mousemove", onMove);
|
|
114
|
+
window.addEventListener("mouseup", onUp);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="grid h-dvh min-h-0 overflow-hidden" style={shellStyle}>
|
|
119
|
+
<Sidebar />
|
|
120
|
+
|
|
121
|
+
<div
|
|
122
|
+
className={
|
|
123
|
+
leftCollapsed
|
|
124
|
+
? "w-0 pointer-events-none"
|
|
125
|
+
: "w-1 cursor-col-resize bg-transparent hover:bg-slate-600"
|
|
126
|
+
}
|
|
127
|
+
onMouseDown={(event) => {
|
|
128
|
+
if (!leftCollapsed) startResize("left", event);
|
|
129
|
+
}}
|
|
130
|
+
role="separator"
|
|
131
|
+
aria-orientation="vertical"
|
|
132
|
+
aria-label="Resize sidebar"
|
|
133
|
+
/>
|
|
134
|
+
|
|
135
|
+
<div className="grid min-h-0 min-w-0 grid-rows-[64px_1fr]">
|
|
136
|
+
<Topbar projectName="testProjectV2" />
|
|
137
|
+
<main className="min-h-0 overflow-auto p-4">
|
|
138
|
+
<ErrorBoundary>{children}</ErrorBoundary>
|
|
139
|
+
</main>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div
|
|
143
|
+
className={
|
|
144
|
+
rightCollapsed
|
|
145
|
+
? "w-0 pointer-events-none"
|
|
146
|
+
: "w-1 cursor-col-resize bg-transparent hover:bg-slate-600"
|
|
147
|
+
}
|
|
148
|
+
onMouseDown={(event) => {
|
|
149
|
+
if (!rightCollapsed) startResize("right", event);
|
|
150
|
+
}}
|
|
151
|
+
role="separator"
|
|
152
|
+
aria-orientation="vertical"
|
|
153
|
+
aria-label="Resize inspector"
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<ErrorBoundary>
|
|
157
|
+
<InspectorPanel />
|
|
158
|
+
</ErrorBoundary>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function AppShell({ children }: { children: ReactNode }) {
|
|
164
|
+
return (
|
|
165
|
+
<WorkspaceProvider>
|
|
166
|
+
<InspectorProvider>
|
|
167
|
+
<AppShellContent>{children}</AppShellContent>
|
|
168
|
+
</InspectorProvider>
|
|
169
|
+
</WorkspaceProvider>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type ErrorBoundaryProps = {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
fallback?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ErrorBoundaryState = {
|
|
11
|
+
hasError: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
16
|
+
constructor(props: ErrorBoundaryProps) {
|
|
17
|
+
super(props);
|
|
18
|
+
this.state = { hasError: false, error: null };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
22
|
+
return { hasError: true, error };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override render() {
|
|
26
|
+
if (this.state.hasError) {
|
|
27
|
+
if (this.props.fallback) {
|
|
28
|
+
return this.props.fallback;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<div className="rounded-xl border border-slate-600 bg-slate-900/90 p-6 text-center">
|
|
32
|
+
<h3 className="mb-2">Something went wrong</h3>
|
|
33
|
+
<p className="mb-3 text-slate-400">
|
|
34
|
+
A component encountered an error. This does not affect other panels.
|
|
35
|
+
</p>
|
|
36
|
+
<pre className="inline-block max-w-[80%] whitespace-pre-wrap rounded-md border border-slate-600 bg-slate-950 px-2 py-1 font-mono text-xs">
|
|
37
|
+
{this.state.error?.message ?? "Unknown error"}
|
|
38
|
+
</pre>
|
|
39
|
+
<div className="mt-4">
|
|
40
|
+
<button
|
|
41
|
+
className="rounded-lg border border-slate-600 px-3 py-2 text-sm"
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
44
|
+
>
|
|
45
|
+
Try Again
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return this.props.children;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@repo/ui/badge";
|
|
4
|
+
import { Handle, NodeProps, NodeToolbar, Position, useStore } from "reactflow";
|
|
5
|
+
import type { ArchNodeData } from "./graph-types";
|
|
6
|
+
|
|
7
|
+
export function ArchNode({ data, selected }: NodeProps<ArchNodeData>) {
|
|
8
|
+
const zoom = useStore((state) => state.transform[2]);
|
|
9
|
+
const detailLevel = zoom < 0.85 ? 0 : zoom < 1.1 ? 1 : 2;
|
|
10
|
+
const titleStyle =
|
|
11
|
+
detailLevel === 0
|
|
12
|
+
? {
|
|
13
|
+
whiteSpace: "nowrap" as const,
|
|
14
|
+
overflow: "hidden",
|
|
15
|
+
textOverflow: "ellipsis",
|
|
16
|
+
}
|
|
17
|
+
: detailLevel === 1
|
|
18
|
+
? {
|
|
19
|
+
display: "-webkit-box",
|
|
20
|
+
WebkitBoxOrient: "vertical" as const,
|
|
21
|
+
WebkitLineClamp: 2,
|
|
22
|
+
overflow: "hidden",
|
|
23
|
+
lineHeight: "1.2",
|
|
24
|
+
}
|
|
25
|
+
: {
|
|
26
|
+
whiteSpace: "normal" as const,
|
|
27
|
+
lineHeight: "1.25",
|
|
28
|
+
};
|
|
29
|
+
const toneClass =
|
|
30
|
+
data.tone === "domain"
|
|
31
|
+
? "bg-blue-900/90"
|
|
32
|
+
: data.tone === "decision"
|
|
33
|
+
? "bg-violet-900/90"
|
|
34
|
+
: data.tone === "phase"
|
|
35
|
+
? "bg-green-900/90"
|
|
36
|
+
: data.tone === "task"
|
|
37
|
+
? "bg-amber-900/90"
|
|
38
|
+
: "bg-slate-700";
|
|
39
|
+
|
|
40
|
+
const metadataPreviewCount = detailLevel === 0 ? 1 : detailLevel === 1 ? 2 : 4;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className={`w-[280px] rounded-xl border border-slate-600 px-2.5 py-2 text-slate-100 shadow ${toneClass}`}>
|
|
44
|
+
<NodeToolbar isVisible={selected} position={Position.Top}>
|
|
45
|
+
<div className="flex items-center gap-2 rounded-md border border-slate-600 bg-slate-950 px-2 py-1">
|
|
46
|
+
<Badge variant="secondary">{data.kind}</Badge>
|
|
47
|
+
<span className="text-xs text-slate-400">drag, connect</span>
|
|
48
|
+
</div>
|
|
49
|
+
</NodeToolbar>
|
|
50
|
+
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !border !border-slate-900 !bg-slate-200" />
|
|
51
|
+
<div className="mb-1 text-[13px] font-semibold" style={titleStyle}>
|
|
52
|
+
{data.label}
|
|
53
|
+
</div>
|
|
54
|
+
{detailLevel > 0 && data.subtitle ? (
|
|
55
|
+
<div className="mb-1.5 text-xs leading-tight text-slate-300">{data.subtitle}</div>
|
|
56
|
+
) : null}
|
|
57
|
+
{data.metadata.length > 0 ? (
|
|
58
|
+
<div
|
|
59
|
+
className="text-[11px] leading-tight text-slate-400"
|
|
60
|
+
style={{
|
|
61
|
+
whiteSpace: detailLevel === 2 ? ("normal" as const) : ("nowrap" as const),
|
|
62
|
+
overflow: "hidden",
|
|
63
|
+
textOverflow: "ellipsis",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{data.metadata
|
|
67
|
+
.slice(0, metadataPreviewCount)
|
|
68
|
+
.map((item) => `${item.label}: ${item.value}`)
|
|
69
|
+
.join(" · ")}
|
|
70
|
+
</div>
|
|
71
|
+
) : null}
|
|
72
|
+
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !border !border-slate-900 !bg-slate-200" />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const nodeTypes = { archNode: ArchNode };
|