@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/Dockerfile +69 -0
- package/LICENSE +205 -0
- package/README.md +201 -0
- package/app/accountability/page.tsx +95 -0
- package/app/api/ceremonies/route.ts +52 -0
- package/app/api/directions/route.ts +6 -0
- package/app/api/edges/route.ts +27 -0
- package/app/api/health/route.ts +37 -0
- package/app/api/narrative/beats/route.ts +29 -0
- package/app/api/narrative/cycles/route.ts +23 -0
- package/app/api/nodes/route.ts +52 -0
- package/app/api/resources/route.ts +48 -0
- package/app/ceremonies/page.tsx +161 -0
- package/app/globals.css +68 -0
- package/app/graph/page.tsx +200 -0
- package/app/layout.tsx +24 -0
- package/app/narrative/beats/page.tsx +145 -0
- package/app/narrative/cycles/page.tsx +143 -0
- package/app/narrative/page.tsx +113 -0
- package/app/nodes/page.tsx +199 -0
- package/app/page.tsx +148 -0
- package/app/relations/page.tsx +191 -0
- package/components/direction-panel.tsx +96 -0
- package/components/navigation.tsx +105 -0
- package/components/theme-provider.tsx +11 -0
- package/components/workspaces-panel.tsx +110 -0
- package/dist/cli/mw.js +731 -0
- package/dist/cli/mwsrv.js +267 -0
- package/docker-build-push.sh +15 -0
- package/docker-entrypoint.sh +26 -0
- package/lib/jsonl-store.ts +586 -0
- package/lib/store.ts +226 -0
- package/lib/types.ts +23 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +5 -0
- package/package.json +112 -0
- package/postcss.config.mjs +6 -0
- package/public/fonts/Stereohead.otf +0 -0
- package/tsconfig.json +21 -0
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
|
+
}
|