@medicine-wheel/app 0.2.3

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/app/page.tsx ADDED
@@ -0,0 +1,148 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { DIRECTIONS, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
5
+ import { DirectionPanel } from "@/components/direction-panel";
6
+
7
+ export default function HomePage() {
8
+ const [selected, setSelected] = useState<DirectionName | null>(null);
9
+ const [hovered, setHovered] = useState<DirectionName | null>(null);
10
+
11
+ const selectedDir = selected ? DIRECTIONS.find((d) => d.name === selected) : null;
12
+
13
+ const R = 200;
14
+ const CX = 250;
15
+ const CY = 250;
16
+
17
+ function quadrantPath(startAngle: number, endAngle: number) {
18
+ const toRad = (a: number) => (a * Math.PI) / 180;
19
+ const x1 = CX + R * Math.cos(toRad(startAngle));
20
+ const y1 = CY + R * Math.sin(toRad(startAngle));
21
+ const x2 = CX + R * Math.cos(toRad(endAngle));
22
+ const y2 = CY + R * Math.sin(toRad(endAngle));
23
+ return `M ${CX} ${CY} L ${x1} ${y1} A ${R} ${R} 0 0 1 ${x2} ${y2} Z`;
24
+ }
25
+
26
+ const quadrants: { dir: DirectionName; start: number; end: number }[] = [
27
+ { dir: "east", start: -45, end: 45 },
28
+ { dir: "south", start: 45, end: 135 },
29
+ { dir: "west", start: 135, end: 225 },
30
+ { dir: "north", start: 225, end: 315 },
31
+ ];
32
+
33
+ return (
34
+ <div className="flex flex-col items-center gap-8 p-6">
35
+ <div className="text-center max-w-2xl">
36
+ <h1 className="text-3xl font-bold mb-2">Medicine Wheel</h1>
37
+ <p className="text-muted-foreground">
38
+ Navigate the Four Directions — click a direction to explore its teachings, ceremonies, and relational obligations.
39
+ </p>
40
+ </div>
41
+
42
+ <div className="relative">
43
+ <svg viewBox="0 0 500 500" className="w-full max-w-[500px]" aria-label="Medicine Wheel">
44
+ {quadrants.map(({ dir, start, end }) => {
45
+ const dirData = DIRECTIONS.find((d) => d.name === dir)!;
46
+ const isHovered = hovered === dir;
47
+ const isSelected = selected === dir;
48
+ const toRad = (a: number) => (a * Math.PI) / 180;
49
+ const midAngle = (start + end) / 2;
50
+ const labelR = R * 0.6;
51
+ const lx = CX + labelR * Math.cos(toRad(midAngle));
52
+ const ly = CY + labelR * Math.sin(toRad(midAngle));
53
+
54
+ return (
55
+ <g key={dir}>
56
+ <path
57
+ d={quadrantPath(start, end)}
58
+ fill={DIRECTION_COLORS[dir]}
59
+ stroke="var(--color-border)"
60
+ strokeWidth={2}
61
+ opacity={isHovered || isSelected ? 1 : 0.75}
62
+ className="cursor-pointer transition-all duration-300"
63
+ style={{
64
+ filter: isHovered ? "brightness(1.25)" : undefined,
65
+ transform: isSelected ? `scale(1.02)` : undefined,
66
+ transformOrigin: `${CX}px ${CY}px`,
67
+ }}
68
+ onClick={() => setSelected(selected === dir ? null : dir)}
69
+ onMouseEnter={() => setHovered(dir)}
70
+ onMouseLeave={() => setHovered(null)}
71
+ />
72
+ <text
73
+ x={lx}
74
+ y={ly - 8}
75
+ textAnchor="middle"
76
+ className="pointer-events-none text-sm font-bold"
77
+ style={{ fill: dir === "east" || dir === "north" ? "#1a1a2e" : "#f5f5f5" }}
78
+ >
79
+ {dirData.ojibwe}
80
+ </text>
81
+ <text
82
+ x={lx}
83
+ y={ly + 10}
84
+ textAnchor="middle"
85
+ className="pointer-events-none text-xs"
86
+ style={{ fill: dir === "east" || dir === "north" ? "#333" : "#ccc" }}
87
+ >
88
+ {dirData.season} · {dirData.medicine[0]}
89
+ </text>
90
+ </g>
91
+ );
92
+ })}
93
+
94
+ {/* Center circle */}
95
+ <circle
96
+ cx={CX}
97
+ cy={CY}
98
+ r={30}
99
+ fill="var(--color-background)"
100
+ stroke="var(--color-border)"
101
+ strokeWidth={2}
102
+ className="animate-pulse-center"
103
+ />
104
+ <text
105
+ x={CX}
106
+ y={CY + 4}
107
+ textAnchor="middle"
108
+ className="pointer-events-none text-[10px] font-semibold fill-foreground"
109
+ >
110
+ Balance
111
+ </text>
112
+ </svg>
113
+ </div>
114
+
115
+ {/* Direction cards grid */}
116
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full max-w-4xl">
117
+ {DIRECTIONS.map((dir) => (
118
+ <button
119
+ key={dir.name}
120
+ onClick={() => setSelected(selected === dir.name ? null : dir.name)}
121
+ className={`p-4 rounded-lg border text-left transition-all hover:scale-[1.02] ${
122
+ selected === dir.name ? "ring-2 ring-primary" : ""
123
+ }`}
124
+ style={{ borderColor: DIRECTION_COLORS[dir.name] + "60" }}
125
+ >
126
+ <div className="flex items-center gap-2 mb-2">
127
+ <span
128
+ className="w-3 h-3 rounded-full"
129
+ style={{ backgroundColor: DIRECTION_COLORS[dir.name] }}
130
+ />
131
+ <span className="font-semibold capitalize">{dir.name}</span>
132
+ </div>
133
+ <p className="text-xs text-muted-foreground">{dir.ojibwe}</p>
134
+ <p className="text-xs text-muted-foreground">{dir.season}</p>
135
+ </button>
136
+ ))}
137
+ </div>
138
+
139
+ {/* Direction Panel */}
140
+ {selectedDir && (
141
+ <DirectionPanel
142
+ direction={selectedDir}
143
+ onClose={() => setSelected(null)}
144
+ />
145
+ )}
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useRef } from "react";
4
+ import type { MouseEvent, FormEvent } from "react";
5
+ import { type RelationalNode, type RelationalEdge, NODE_TYPE_COLORS, type NodeType } from "@/lib/types";
6
+ import { toast } from "sonner";
7
+
8
+ interface GraphNode extends RelationalNode {
9
+ x: number;
10
+ y: number;
11
+ }
12
+
13
+ export default function RelationsPage() {
14
+ const [nodes, setNodes] = useState<GraphNode[]>([]);
15
+ const [edges, setEdges] = useState<RelationalEdge[]>([]);
16
+ const [selected, setSelected] = useState<string | null>(null);
17
+ const [filterType, setFilterType] = useState<string>("all");
18
+ const [showAddNode, setShowAddNode] = useState(false);
19
+ const svgRef = useRef<SVGSVGElement>(null);
20
+ const [dragging, setDragging] = useState<string | null>(null);
21
+
22
+ const loadData = useCallback(async () => {
23
+ try {
24
+ const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
25
+ const nodesData: RelationalNode[] = await nodesRes.json();
26
+ const edgesData: RelationalEdge[] = await edgesRes.json();
27
+
28
+ const cx = 400, cy = 300, radius = 220;
29
+ const graphNodes = nodesData.map((n, i) => {
30
+ const angle = (2 * Math.PI * i) / Math.max(nodesData.length, 1);
31
+ return { ...n, x: cx + radius * Math.cos(angle) + (Math.random() - 0.5) * 40, y: cy + radius * Math.sin(angle) + (Math.random() - 0.5) * 40 };
32
+ });
33
+ setNodes(graphNodes);
34
+ setEdges(Array.isArray(edgesData) ? edgesData : []);
35
+ } catch { console.error("Failed to load"); }
36
+ }, []);
37
+
38
+ useEffect(() => { loadData(); }, [loadData]);
39
+
40
+ const handleMouseDown = (id: string) => setDragging(id);
41
+ const handleMouseUp = () => setDragging(null);
42
+ const handleMouseMove = (e: MouseEvent<SVGSVGElement>) => {
43
+ if (!dragging || !svgRef.current) return;
44
+ const rect = svgRef.current.getBoundingClientRect();
45
+ const x = ((e.clientX - rect.left) / rect.width) * 800;
46
+ const y = ((e.clientY - rect.top) / rect.height) * 600;
47
+ setNodes((prev) => prev.map((n) => (n.id === dragging ? { ...n, x, y } : n)));
48
+ };
49
+
50
+ const filteredNodes = filterType === "all" ? nodes : nodes.filter((n) => n.type === filterType);
51
+ const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
52
+ const filteredEdges = edges.filter((e) => filteredNodeIds.has(e.from_id) && filteredNodeIds.has(e.to_id));
53
+ const selectedNode = nodes.find((n) => n.id === selected);
54
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
55
+
56
+ const types: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
57
+
58
+ // Simple accountability stats
59
+ const totalEdges = edges.length;
60
+ const ceremonied = edges.filter((e) => e.ceremony_honored).length;
61
+ const unceremonied = totalEdges - ceremonied;
62
+ const avgStrength = totalEdges > 0 ? edges.reduce((s, e) => s + e.strength, 0) / totalEdges : 0;
63
+
64
+ async function addNode(e: FormEvent<HTMLFormElement>) {
65
+ e.preventDefault();
66
+ const form = new FormData(e.currentTarget);
67
+ const body = { name: form.get("name") as string, type: form.get("type") as string, direction: (form.get("direction") as string) || undefined };
68
+ const res = await fetch("/api/nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
69
+ if (res.ok) { toast.success("Node created"); setShowAddNode(false); loadData(); } else { toast.error("Failed"); }
70
+ }
71
+
72
+ return (
73
+ <div className="p-6 max-w-7xl mx-auto">
74
+ <div className="flex items-center justify-between mb-6">
75
+ <div>
76
+ <h1 className="text-2xl font-bold">Relational Web</h1>
77
+ <p className="text-sm text-muted-foreground">{nodes.length} nodes · {edges.length} edges</p>
78
+ </div>
79
+ <div className="flex gap-2">
80
+ <select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-1.5 rounded-md border bg-background text-sm">
81
+ <option value="all">All Types</option>
82
+ {types.map((t) => <option key={t} value={t}>{t}</option>)}
83
+ </select>
84
+ <button onClick={() => setShowAddNode(!showAddNode)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Add Node</button>
85
+ </div>
86
+ </div>
87
+
88
+ {showAddNode && (
89
+ <form onSubmit={addNode} className="mb-6 p-4 border rounded-lg bg-card grid grid-cols-1 sm:grid-cols-4 gap-3">
90
+ <input name="name" placeholder="Node name" required className="px-3 py-2 rounded-md border bg-background text-sm" />
91
+ <select name="type" required className="px-3 py-2 rounded-md border bg-background text-sm">{types.map((t) => <option key={t} value={t}>{t}</option>)}</select>
92
+ <select name="direction" className="px-3 py-2 rounded-md border bg-background text-sm">
93
+ <option value="">No direction</option><option value="east">East</option><option value="south">South</option><option value="west">West</option><option value="north">North</option>
94
+ </select>
95
+ <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create</button>
96
+ </form>
97
+ )}
98
+
99
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
100
+ <div className="lg:col-span-2 border rounded-lg bg-card overflow-hidden">
101
+ <svg ref={svgRef} viewBox="0 0 800 600" className="w-full h-[600px] cursor-crosshair"
102
+ onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
103
+ {filteredEdges.map((edge) => {
104
+ const from = nodeMap.get(edge.from_id);
105
+ const to = nodeMap.get(edge.to_id);
106
+ if (!from || !to) return null;
107
+ return (
108
+ <line key={edge.id} x1={from.x} y1={from.y} x2={to.x} y2={to.y}
109
+ stroke={edge.ceremony_honored ? "var(--color-primary)" : "var(--color-muted-foreground)"}
110
+ strokeWidth={1 + edge.strength * 3} strokeDasharray={edge.ceremony_honored ? "none" : "6,4"} opacity={0.6} />
111
+ );
112
+ })}
113
+ {filteredNodes.map((node) => (
114
+ <g key={node.id} className="cursor-grab" onMouseDown={() => handleMouseDown(node.id)} onClick={() => setSelected(node.id)}>
115
+ <circle cx={node.x} cy={node.y} r={selected === node.id ? 20 : 16}
116
+ fill={NODE_TYPE_COLORS[node.type]} stroke={selected === node.id ? "var(--color-ring)" : "var(--color-border)"}
117
+ strokeWidth={selected === node.id ? 3 : 1} opacity={0.9} />
118
+ <text x={node.x} y={node.y + 28} textAnchor="middle" className="fill-foreground text-[10px] pointer-events-none">
119
+ {node.name.length > 15 ? node.name.slice(0, 14) + "…" : node.name}
120
+ </text>
121
+ </g>
122
+ ))}
123
+ </svg>
124
+ </div>
125
+
126
+ <div className="border rounded-lg bg-card p-4">
127
+ {selectedNode ? (
128
+ <div>
129
+ <h3 className="text-lg font-semibold flex items-center gap-2">
130
+ <span className="w-3 h-3 rounded-full" style={{ backgroundColor: NODE_TYPE_COLORS[selectedNode.type] }} />
131
+ {selectedNode.name}
132
+ </h3>
133
+ <div className="mt-3 space-y-2 text-sm">
134
+ <div><span className="text-muted-foreground">Type:</span> {selectedNode.type}</div>
135
+ {selectedNode.direction && <div><span className="text-muted-foreground">Direction:</span> {selectedNode.direction}</div>}
136
+ <div><span className="text-muted-foreground">Created:</span> {new Date(selectedNode.created_at).toLocaleDateString()}</div>
137
+ </div>
138
+ <div className="mt-4">
139
+ <h4 className="text-sm font-medium mb-2">Connected Edges</h4>
140
+ {edges.filter((e) => e.from_id === selectedNode.id || e.to_id === selectedNode.id).map((e) => {
141
+ const other = e.from_id === selectedNode.id ? nodeMap.get(e.to_id) : nodeMap.get(e.from_id);
142
+ return (
143
+ <div key={e.id} className="py-1 text-sm border-b border-border/50">
144
+ <span className="text-muted-foreground">{e.relationship_type}</span> → {other?.name || "?"}
145
+ <span className="ml-2 text-xs text-muted-foreground">({Math.round(e.strength * 100)}%)</span>
146
+ {e.ceremony_honored && <span className="ml-1 text-green-500">✓</span>}
147
+ </div>
148
+ );
149
+ })}
150
+ </div>
151
+ </div>
152
+ ) : (
153
+ <div className="text-center text-muted-foreground py-8">
154
+ <p className="text-sm">Click a node to view details</p>
155
+ <div className="mt-4 text-left">
156
+ <h4 className="text-sm font-medium mb-2">Legend</h4>
157
+ {types.map((t) => (
158
+ <div key={t} className="flex items-center gap-2 text-sm py-0.5">
159
+ <span className="w-3 h-3 rounded-full" style={{ backgroundColor: NODE_TYPE_COLORS[t] }} /> <span className="capitalize">{t}</span>
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </div>
164
+ )}
165
+ </div>
166
+ </div>
167
+
168
+ {/* Relational Health */}
169
+ {nodes.length > 0 && (
170
+ <div className="mt-6 border rounded-lg bg-card p-4">
171
+ <h3 className="text-sm font-semibold text-muted-foreground uppercase mb-4">Relational Health</h3>
172
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
173
+ <div>
174
+ <svg viewBox="0 0 48 48" className="w-12 h-12 mx-auto">
175
+ <circle cx="24" cy="24" r="20" fill="none" stroke="var(--color-muted)" strokeWidth="4" />
176
+ <circle cx="24" cy="24" r="20" fill="none" stroke="var(--color-primary)" strokeWidth="4"
177
+ strokeDasharray={`${avgStrength * 125.6} 125.6`} strokeLinecap="round" transform="rotate(-90 24 24)" />
178
+ <text x="24" y="28" textAnchor="middle" className="text-[10px] fill-foreground font-bold">{Math.round(avgStrength * 100)}%</text>
179
+ </svg>
180
+ <p className="text-xs text-muted-foreground mt-1">Average strength</p>
181
+ </div>
182
+ <div><p className="text-2xl font-bold">{ceremonied}</p><p className="text-xs text-muted-foreground">Ceremonied</p></div>
183
+ <div><p className="text-2xl font-bold">{unceremonied}</p><p className="text-xs text-muted-foreground">Unceremonied</p></div>
184
+ <div><p className="text-2xl font-bold text-green-500">{ceremonied}</p><p className="text-xs text-muted-foreground">Ceremonies honored</p></div>
185
+ <div><p className="text-2xl font-bold">{unceremonied}</p><p className="text-xs text-muted-foreground">Obligations</p></div>
186
+ </div>
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,96 @@
1
+ "use client";
2
+
3
+ import { type Direction, DIRECTION_COLORS, CEREMONY_ICONS } from "@/lib/types";
4
+ import { X } from "lucide-react";
5
+ import Link from "next/link";
6
+
7
+ interface DirectionPanelProps {
8
+ direction: Direction;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export function DirectionPanel({ direction, onClose }: DirectionPanelProps) {
13
+ return (
14
+ <div className="w-full max-w-2xl rounded-xl border bg-card p-6 shadow-lg animate-in slide-in-from-bottom-4">
15
+ <div className="flex items-start justify-between mb-4">
16
+ <div>
17
+ <h2 className="text-2xl font-bold flex items-center gap-3">
18
+ <span
19
+ className="w-4 h-4 rounded-full inline-block"
20
+ style={{ backgroundColor: DIRECTION_COLORS[direction.name] }}
21
+ />
22
+ {direction.ojibwe}
23
+ <span className="text-muted-foreground text-lg capitalize">({direction.name})</span>
24
+ </h2>
25
+ <p className="text-muted-foreground">
26
+ {direction.season} · {direction.lifeStage} · {direction.ages}
27
+ </p>
28
+ </div>
29
+ <button onClick={onClose} className="p-1 rounded hover:bg-secondary" aria-label="Close">
30
+ <X className="h-5 w-5" />
31
+ </button>
32
+ </div>
33
+
34
+ <div className="grid gap-4 md:grid-cols-2">
35
+ <div>
36
+ <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
37
+ Medicines
38
+ </h3>
39
+ <div className="flex flex-wrap gap-2">
40
+ {direction.medicine.map((m) => (
41
+ <span
42
+ key={m}
43
+ className="px-3 py-1 rounded-full text-sm border"
44
+ style={{ borderColor: DIRECTION_COLORS[direction.name] }}
45
+ >
46
+ 🌿 {m}
47
+ </span>
48
+ ))}
49
+ </div>
50
+ </div>
51
+
52
+ <div>
53
+ <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
54
+ Teachings
55
+ </h3>
56
+ <ul className="space-y-1">
57
+ {direction.teachings.map((t) => (
58
+ <li key={t} className="text-sm flex items-center gap-2">
59
+ <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: DIRECTION_COLORS[direction.name] }} />
60
+ {t}
61
+ </li>
62
+ ))}
63
+ </ul>
64
+ </div>
65
+
66
+ <div className="md:col-span-2">
67
+ <h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
68
+ Practices
69
+ </h3>
70
+ <div className="flex flex-wrap gap-2">
71
+ {direction.practices.map((p) => (
72
+ <span key={p} className="px-3 py-1 rounded-md text-sm bg-secondary">
73
+ {p}
74
+ </span>
75
+ ))}
76
+ </div>
77
+ </div>
78
+ </div>
79
+
80
+ <div className="flex gap-3 mt-6 pt-4 border-t">
81
+ <Link
82
+ href={`/ceremonies?direction=${direction.name}`}
83
+ className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
84
+ >
85
+ View Ceremonies
86
+ </Link>
87
+ <Link
88
+ href={`/relations?direction=${direction.name}`}
89
+ className="px-4 py-2 rounded-md border text-sm font-medium hover:bg-secondary"
90
+ >
91
+ View Relations
92
+ </Link>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { cn } from "@/lib/utils";
6
+ import { Menu, X, ChevronDown } from "lucide-react";
7
+ import { useState } from "react";
8
+ import { WorkspacesPanel, WORKSPACES, type Workspace } from "@/components/workspaces-panel";
9
+
10
+ const NAV_ITEMS = [
11
+ { href: "/", label: "Medicine Wheel", color: "#FFD700" },
12
+ { href: "/graph", label: "Graph", color: "#d4b844" },
13
+ { href: "/nodes", label: "Nodes", color: "#4a9e5c" },
14
+ { href: "/relations", label: "Relations", color: "#5a9ec6" },
15
+ { href: "/ceremonies", label: "Ceremonies", color: "#DC143C" },
16
+ { href: "/narrative", label: "Narrative", color: "#9a5cc6" },
17
+ { href: "/narrative/beats", label: "Beats", color: "#c9a23a" },
18
+ { href: "/narrative/cycles", label: "Cycles", color: "#e8913a" },
19
+ { href: "/accountability", label: "Accountability", color: "#E8E8E8" },
20
+ ];
21
+
22
+ export function Navigation() {
23
+ const pathname = usePathname();
24
+ const [mobileOpen, setMobileOpen] = useState(false);
25
+ const [wsOpen, setWsOpen] = useState(false);
26
+ const [activeWs, setActiveWs] = useState<Workspace>(WORKSPACES[0]);
27
+
28
+ return (
29
+ <>
30
+ <nav className="fixed top-0 left-0 right-0 z-40 border-b border-border bg-background/80 backdrop-blur-md">
31
+ <div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-3 px-4">
32
+ <button
33
+ onClick={() => setWsOpen(true)}
34
+ className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-secondary"
35
+ aria-label="Open workspaces"
36
+ >
37
+ <span className="h-2 w-2 rounded-full" style={{ background: activeWs.color }} />
38
+ <span
39
+ className="hidden sm:inline text-base"
40
+ style={{ fontFamily: "var(--font-display)" }}
41
+ >
42
+ {activeWs.name}
43
+ </span>
44
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
45
+ </button>
46
+
47
+ <div className="hidden md:flex items-center gap-1 overflow-hidden">
48
+ {NAV_ITEMS.map((item) => (
49
+ <Link
50
+ key={item.href}
51
+ href={item.href}
52
+ className={cn(
53
+ "whitespace-nowrap px-3 py-2 rounded-md text-sm font-medium transition-colors",
54
+ pathname === item.href
55
+ ? "bg-secondary text-foreground"
56
+ : "text-muted-foreground hover:text-foreground hover:bg-secondary/50"
57
+ )}
58
+ style={pathname === item.href ? { borderBottom: `2px solid ${item.color}` } : {}}
59
+ >
60
+ {item.label}
61
+ </Link>
62
+ ))}
63
+ </div>
64
+
65
+ <div className="flex items-center gap-2">
66
+ <button
67
+ onClick={() => setMobileOpen(!mobileOpen)}
68
+ className="md:hidden p-2 rounded-md hover:bg-secondary"
69
+ aria-label="Toggle menu"
70
+ >
71
+ {mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
72
+ </button>
73
+ </div>
74
+ </div>
75
+
76
+ {mobileOpen && (
77
+ <div className="md:hidden border-t border-border bg-background px-4 pb-4">
78
+ {NAV_ITEMS.map((item) => (
79
+ <Link
80
+ key={item.href}
81
+ href={item.href}
82
+ onClick={() => setMobileOpen(false)}
83
+ className={cn(
84
+ "block px-3 py-2 rounded-md text-sm font-medium",
85
+ pathname === item.href
86
+ ? "bg-secondary text-foreground"
87
+ : "text-muted-foreground hover:text-foreground"
88
+ )}
89
+ >
90
+ {item.label}
91
+ </Link>
92
+ ))}
93
+ </div>
94
+ )}
95
+ </nav>
96
+
97
+ <WorkspacesPanel
98
+ open={wsOpen}
99
+ activeId={activeWs.id}
100
+ onClose={() => setWsOpen(false)}
101
+ onSelect={(ws) => { setActiveWs(ws); setWsOpen(false); }}
102
+ />
103
+ </>
104
+ );
105
+ }
@@ -0,0 +1,11 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ThemeProvider as NextThemesProvider } from "next-themes";
5
+
6
+ export function ThemeProvider({
7
+ children,
8
+ ...props
9
+ }: React.ComponentProps<typeof NextThemesProvider>) {
10
+ return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
11
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { X } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export type Workspace = {
8
+ id: string;
9
+ name: string;
10
+ direction: "east" | "south" | "west" | "north";
11
+ color: string;
12
+ status: "active" | "idle" | "archived";
13
+ repo: string;
14
+ blurb: string;
15
+ };
16
+
17
+ export const WORKSPACES: Workspace[] = [
18
+ { id: "medicine-wheel", name: "Medicine Wheel", direction: "east", color: "#FFD700", status: "active", repo: "jgwill/medicine-wheel", blurb: "Relational ontology + Four Directions web app" },
19
+ { id: "iaip", name: "IAIP Platform", direction: "south", color: "#DC143C", status: "active", repo: "jgwill/iaip", blurb: "Three Universes multi-agent orchestration" },
20
+ { id: "stc", name: "STC Workspaces", direction: "west", color: "#8888cc", status: "active", repo: "jgwill/stcraft", blurb: "Structural tension charts across repos" },
21
+ { id: "tushell", name: "Tushell Magic Land", direction: "north", color: "#E8E8E8", status: "idle", repo: "jgwill/tushellplatform", blurb: "Child-friendly knowledge platform" },
22
+ { id: "articles", name: "Articles", direction: "east", color: "#c9a23a", status: "idle", repo: "jgwill/medicine-wheel#articles", blurb: "Narrative-technical research" },
23
+ { id: "pde", name: "PDE", direction: "south", color: "#9a5cc6", status: "active", repo: "jgwill/medicine-wheel#pde", blurb: "Four Directions prompt decomposition" },
24
+ ];
25
+
26
+ const STATUS_DOT: Record<Workspace["status"], string> = {
27
+ active: "bg-[var(--mw-wilson-high)]",
28
+ idle: "bg-[var(--mw-muted)]",
29
+ archived: "bg-[var(--mw-wilson-low)]",
30
+ };
31
+
32
+ export function WorkspacesPanel({
33
+ open,
34
+ activeId,
35
+ onClose,
36
+ onSelect,
37
+ }: {
38
+ open: boolean;
39
+ activeId: string;
40
+ onClose: () => void;
41
+ onSelect: (ws: Workspace) => void;
42
+ }) {
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
46
+ window.addEventListener("keydown", onKey);
47
+ return () => window.removeEventListener("keydown", onKey);
48
+ }, [open, onClose]);
49
+
50
+ return (
51
+ <>
52
+ <div
53
+ aria-hidden={!open}
54
+ onClick={onClose}
55
+ className={cn(
56
+ "fixed inset-0 z-40 bg-background/60 backdrop-blur-sm transition-opacity",
57
+ open ? "opacity-100" : "pointer-events-none opacity-0"
58
+ )}
59
+ />
60
+ <aside
61
+ aria-label="Workspaces"
62
+ className={cn(
63
+ "fixed left-0 top-0 z-50 h-full w-[340px] border-r border-border bg-card transition-transform",
64
+ open ? "translate-x-0" : "-translate-x-full"
65
+ )}
66
+ >
67
+ <div className="flex items-center justify-between border-b border-border px-5 py-4">
68
+ <div className="mw-h2" style={{ fontSize: "1.25rem" }}>Workspaces</div>
69
+ <button
70
+ onClick={onClose}
71
+ className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
72
+ aria-label="Close workspaces panel"
73
+ >
74
+ <X className="h-4 w-4" />
75
+ </button>
76
+ </div>
77
+ <div className="overflow-y-auto px-3 py-3" style={{ maxHeight: "calc(100vh - 60px)" }}>
78
+ {WORKSPACES.map((ws) => {
79
+ const selected = ws.id === activeId;
80
+ return (
81
+ <button
82
+ key={ws.id}
83
+ onClick={() => onSelect(ws)}
84
+ className={cn(
85
+ "mb-2 w-full rounded-lg border p-3 text-left transition-colors",
86
+ selected
87
+ ? "bg-secondary"
88
+ : "border-border bg-background hover:bg-secondary/50"
89
+ )}
90
+ style={{
91
+ borderLeft: `3px solid ${ws.color}`,
92
+ borderTopColor: selected ? ws.color + "60" : undefined,
93
+ borderRightColor: selected ? ws.color + "60" : undefined,
94
+ borderBottomColor: selected ? ws.color + "60" : undefined,
95
+ }}
96
+ >
97
+ <div className="flex items-center gap-2">
98
+ <span className={cn("h-2 w-2 rounded-full", STATUS_DOT[ws.status])} />
99
+ <span className="text-sm font-semibold">{ws.name}</span>
100
+ </div>
101
+ <div className="mt-1 text-xs text-muted-foreground">{ws.blurb}</div>
102
+ <div className="mt-1 font-mono text-[11px] text-muted-foreground/80">{ws.repo}</div>
103
+ </button>
104
+ );
105
+ })}
106
+ </div>
107
+ </aside>
108
+ </>
109
+ );
110
+ }