@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.
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import type { FormEvent } from "react";
5
+ import { type NarrativeBeat, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
6
+ import { toast } from "sonner";
7
+
8
+ export default function BeatsPage() {
9
+ const [beats, setBeats] = useState<NarrativeBeat[]>([]);
10
+ const [viewMode, setViewMode] = useState<"timeline" | "direction">("timeline");
11
+ const [filterDir, setFilterDir] = useState<string>("all");
12
+ const [expandedBeat, setExpandedBeat] = useState<string | null>(null);
13
+ const [showForm, setShowForm] = useState(false);
14
+
15
+ useEffect(() => {
16
+ fetch("/api/narrative/beats").then((r) => r.json()).then((d) => setBeats(Array.isArray(d) ? d : [])).catch(() => setBeats([]));
17
+ }, []);
18
+
19
+ const directions: DirectionName[] = ["east", "south", "west", "north"];
20
+ const filteredBeats = filterDir === "all" ? beats : beats.filter((b) => b.direction === filterDir);
21
+
22
+ async function addBeat(e: FormEvent<HTMLFormElement>) {
23
+ e.preventDefault();
24
+ const form = new FormData(e.currentTarget);
25
+ const body = {
26
+ direction: form.get("direction") as string,
27
+ title: form.get("title") as string,
28
+ description: form.get("description") as string,
29
+ prose: form.get("prose") as string || undefined,
30
+ act: parseInt(form.get("act") as string) || 1,
31
+ learnings: (form.get("learnings") as string).split(",").map((s) => s.trim()).filter(Boolean),
32
+ ceremonies: [],
33
+ relations_honored: [],
34
+ };
35
+ const res = await fetch("/api/narrative/beats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
36
+ if (res.ok) {
37
+ toast.success("Beat created");
38
+ setShowForm(false);
39
+ const data = await fetch("/api/narrative/beats").then((r) => r.json());
40
+ setBeats(Array.isArray(data) ? data : []);
41
+ } else { toast.error("Failed"); }
42
+ }
43
+
44
+ // Direction summary
45
+ const dirCounts = directions.reduce((acc, d) => { acc[d] = beats.filter((b) => b.direction === d).length; return acc; }, {} as Record<DirectionName, number>);
46
+
47
+ // Cadence: which phases are covered
48
+ const dirToPhase: Record<DirectionName, string> = { east: "Opening", south: "Deepening", west: "Integrating", north: "Closing" };
49
+ const visitedDirs = new Set(beats.map((b) => b.direction));
50
+
51
+ return (
52
+ <div className="p-6 max-w-6xl mx-auto">
53
+ <div className="flex items-center justify-between mb-6">
54
+ <div>
55
+ <h1 className="text-2xl font-bold">Narrative Beats</h1>
56
+ <p className="text-sm text-muted-foreground">{beats.length} beats across the Four Directions</p>
57
+ </div>
58
+ <div className="flex gap-2">
59
+ <button onClick={() => setViewMode(viewMode === "timeline" ? "direction" : "timeline")}
60
+ className="px-3 py-1.5 rounded-md border text-sm">{viewMode === "timeline" ? "By Direction" : "Timeline"}</button>
61
+ <button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Add Beat</button>
62
+ </div>
63
+ </div>
64
+
65
+ {/* Direction Summary + Cadence */}
66
+ <div className="grid grid-cols-4 gap-3 mb-6">
67
+ {directions.map((dir) => (
68
+ <button key={dir} onClick={() => setFilterDir(filterDir === dir ? "all" : dir)}
69
+ className={`p-3 rounded-lg border text-center transition-all ${filterDir === dir ? "ring-2 ring-primary" : ""}`}
70
+ style={{ borderColor: DIRECTION_COLORS[dir] + "40" }}>
71
+ <span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor: DIRECTION_COLORS[dir] }} />
72
+ <p className="text-sm capitalize font-medium">{dir}</p>
73
+ <p className="text-lg font-bold">{dirCounts[dir]}</p>
74
+ <p className="text-xs text-muted-foreground">{dirToPhase[dir]} {visitedDirs.has(dir) ? "✓" : ""}</p>
75
+ </button>
76
+ ))}
77
+ </div>
78
+
79
+ {showForm && (
80
+ <form onSubmit={addBeat} className="mb-6 p-4 border rounded-lg bg-card grid grid-cols-1 sm:grid-cols-2 gap-3">
81
+ <input name="title" placeholder="Beat title" required className="px-3 py-2 rounded-md border bg-background text-sm" />
82
+ <select name="direction" required className="px-3 py-2 rounded-md border bg-background text-sm">
83
+ {directions.map((d) => <option key={d} value={d}>{d}</option>)}
84
+ </select>
85
+ <textarea name="description" placeholder="Description" rows={2} required className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
86
+ <textarea name="prose" placeholder="Prose (optional)" rows={2} className="px-3 py-2 rounded-md border bg-background text-sm" />
87
+ <select name="act" className="px-3 py-2 rounded-md border bg-background text-sm">
88
+ <option value="1">Act 1 (East)</option><option value="2">Act 2 (South)</option><option value="3">Act 3 (West)</option><option value="4">Act 4 (North)</option>
89
+ </select>
90
+ <input name="learnings" placeholder="Learnings (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
91
+ <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create Beat</button>
92
+ </form>
93
+ )}
94
+
95
+ {viewMode === "timeline" ? (
96
+ <div className="relative pl-8">
97
+ <div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
98
+ {filteredBeats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats yet.</div>}
99
+ {filteredBeats.map((beat) => (
100
+ <div key={beat.id} className="relative mb-4" onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
101
+ <div className="absolute -left-5 top-4 w-3 h-3 rounded-full border-2 border-background" style={{ backgroundColor: DIRECTION_COLORS[beat.direction as DirectionName] }} />
102
+ <div className="border rounded-lg bg-card p-4 cursor-pointer hover:border-ring/50"
103
+ style={{ borderLeftColor: DIRECTION_COLORS[beat.direction as DirectionName], borderLeftWidth: 3 }}>
104
+ <div className="flex items-center justify-between">
105
+ <div>
106
+ <h3 className="font-medium">{beat.title}</h3>
107
+ <p className="text-sm text-muted-foreground">{beat.description}</p>
108
+ </div>
109
+ <div className="text-xs text-muted-foreground text-right">
110
+ <div className="capitalize">{beat.direction} · Act {beat.act}</div>
111
+ <div>{new Date(beat.timestamp).toLocaleDateString()}</div>
112
+ </div>
113
+ </div>
114
+ {expandedBeat === beat.id && (
115
+ <div className="mt-3 pt-3 border-t space-y-2">
116
+ {beat.prose && <p className="text-sm italic">{beat.prose}</p>}
117
+ {beat.learnings.length > 0 && <div><span className="text-xs font-medium text-muted-foreground">Learnings:</span><ul className="text-sm mt-1">{beat.learnings.map((l, i) => <li key={i}>• {l}</li>)}</ul></div>}
118
+ </div>
119
+ )}
120
+ </div>
121
+ </div>
122
+ ))}
123
+ </div>
124
+ ) : (
125
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
126
+ {directions.map((dir) => (
127
+ <div key={dir} className="space-y-2">
128
+ <h3 className="font-semibold capitalize text-center py-2 rounded-md" style={{ backgroundColor: DIRECTION_COLORS[dir] + "20", color: DIRECTION_COLORS[dir] }}>
129
+ {dir} ({dirCounts[dir]})
130
+ </h3>
131
+ {beats.filter((b) => b.direction === dir).map((beat) => (
132
+ <div key={beat.id} className="border rounded-md bg-card p-3 text-sm cursor-pointer hover:border-ring/50"
133
+ onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
134
+ <p className="font-medium">{beat.title}</p>
135
+ <p className="text-xs text-muted-foreground">Act {beat.act} · {new Date(beat.timestamp).toLocaleDateString()}</p>
136
+ {expandedBeat === beat.id && beat.prose && <p className="mt-2 text-xs italic border-t pt-2">{beat.prose}</p>}
137
+ </div>
138
+ ))}
139
+ </div>
140
+ ))}
141
+ </div>
142
+ )}
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import type { FormEvent } from "react";
5
+ import { type MedicineWheelCycle, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
6
+ import { toast } from "sonner";
7
+
8
+ export default function CyclesPage() {
9
+ const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
10
+ const [showForm, setShowForm] = useState(false);
11
+ const [expandedId, setExpandedId] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ fetch("/api/narrative/cycles").then((r) => r.json()).then((d) => setCycles(Array.isArray(d) ? d : [])).catch(() => setCycles([]));
15
+ }, []);
16
+
17
+ async function createCycle(e: FormEvent<HTMLFormElement>) {
18
+ e.preventDefault();
19
+ const form = new FormData(e.currentTarget);
20
+ const body = { research_question: form.get("question") as string };
21
+ const res = await fetch("/api/narrative/cycles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
22
+ if (res.ok) {
23
+ toast.success("Cycle created");
24
+ setShowForm(false);
25
+ const data = await fetch("/api/narrative/cycles").then((r) => r.json());
26
+ setCycles(Array.isArray(data) ? data : []);
27
+ } else { toast.error("Failed"); }
28
+ }
29
+
30
+ const dirOrder: DirectionName[] = ["east", "south", "west", "north"];
31
+
32
+ return (
33
+ <div className="p-6 max-w-5xl mx-auto">
34
+ <div className="flex items-center justify-between mb-6">
35
+ <div>
36
+ <h1 className="text-2xl font-bold">Medicine Wheel Cycles</h1>
37
+ <p className="text-sm text-muted-foreground">{cycles.length} research cycles</p>
38
+ </div>
39
+ <button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ New Cycle</button>
40
+ </div>
41
+
42
+ {showForm && (
43
+ <form onSubmit={createCycle} className="mb-6 p-4 border rounded-lg bg-card">
44
+ <textarea name="question" placeholder="What is your research question?" rows={3} required className="w-full px-3 py-2 rounded-md border bg-background text-sm mb-3" />
45
+ <div className="flex gap-2">
46
+ <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create Cycle</button>
47
+ <button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-md border text-sm">Cancel</button>
48
+ </div>
49
+ </form>
50
+ )}
51
+
52
+ <div className="space-y-4">
53
+ {cycles.length === 0 && <div className="text-center py-12 text-muted-foreground">No cycles yet. Create one to begin your research journey.</div>}
54
+ {cycles.map((cycle) => {
55
+ const dirIdx = dirOrder.indexOf(cycle.current_direction as DirectionName);
56
+ const progress = ((dirIdx + 1) / 4) * 100;
57
+
58
+ return (
59
+ <div key={cycle.id} className="border rounded-xl bg-card overflow-hidden cursor-pointer hover:border-ring/50"
60
+ onClick={() => setExpandedId(expandedId === cycle.id ? null : cycle.id)}>
61
+ <div className="p-6">
62
+ <div className="flex items-start gap-4">
63
+ {/* Mini wheel */}
64
+ <svg viewBox="0 0 48 48" className="w-12 h-12 flex-shrink-0">
65
+ {dirOrder.map((dir, i) => {
66
+ const startAngle = i * 90 - 45;
67
+ const endAngle = startAngle + 90;
68
+ const toRad = (a: number) => (a * Math.PI) / 180;
69
+ const x1 = 24 + 20 * Math.cos(toRad(startAngle));
70
+ const y1 = 24 + 20 * Math.sin(toRad(startAngle));
71
+ const x2 = 24 + 20 * Math.cos(toRad(endAngle));
72
+ const y2 = 24 + 20 * Math.sin(toRad(endAngle));
73
+ const completed = dirOrder.indexOf(dir) <= dirIdx;
74
+ return (
75
+ <path key={dir}
76
+ d={`M 24 24 L ${x1} ${y1} A 20 20 0 0 1 ${x2} ${y2} Z`}
77
+ fill={DIRECTION_COLORS[dir]}
78
+ opacity={completed ? 0.9 : 0.2}
79
+ />
80
+ );
81
+ })}
82
+ <circle cx="24" cy="24" r="4" fill="var(--color-background)" />
83
+ </svg>
84
+
85
+ <div className="flex-1 min-w-0">
86
+ <h3 className="font-semibold mb-1">{cycle.research_question}</h3>
87
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
88
+ <span className="capitalize" style={{ color: DIRECTION_COLORS[cycle.current_direction as DirectionName] }}>
89
+ ● {cycle.current_direction}
90
+ </span>
91
+ <span>{cycle.beats.length} beats</span>
92
+ <span>{cycle.ceremonies_conducted} ceremonies</span>
93
+ <span>Started {new Date(cycle.start_date).toLocaleDateString()}</span>
94
+ </div>
95
+
96
+ {/* Progress bar */}
97
+ <div className="mt-3 h-2 bg-secondary rounded-full overflow-hidden">
98
+ <div className="h-full rounded-full transition-all" style={{
99
+ width: `${progress}%`,
100
+ background: `linear-gradient(to right, ${DIRECTION_COLORS.east}, ${DIRECTION_COLORS.south}, ${DIRECTION_COLORS.west}, ${DIRECTION_COLORS.north})`,
101
+ }} />
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ {expandedId === cycle.id && (
108
+ <div className="px-6 pb-6 border-t pt-4">
109
+ <div className="grid grid-cols-4 gap-3 mb-4">
110
+ {dirOrder.map((dir) => {
111
+ const active = dirOrder.indexOf(dir) <= dirIdx;
112
+ return (
113
+ <div key={dir} className="text-center p-2 rounded-md border" style={{ borderColor: active ? DIRECTION_COLORS[dir] : "var(--color-border)", opacity: active ? 1 : 0.4 }}>
114
+ <p className="text-xs capitalize font-medium">{dir}</p>
115
+ <p className="text-sm">{active ? "✓" : "○"}</p>
116
+ </div>
117
+ );
118
+ })}
119
+ </div>
120
+ <div className="grid grid-cols-3 gap-4 text-center text-sm">
121
+ <div>
122
+ <p className="text-muted-foreground">Wilson Alignment</p>
123
+ <p className="font-bold">{Math.round(cycle.wilson_alignment * 100)}%</p>
124
+ </div>
125
+ <div>
126
+ <p className="text-muted-foreground">OCAP</p>
127
+ <p className="font-bold">{cycle.ocap_compliant ? "✅ Compliant" : "⚠️ Pending"}</p>
128
+ </div>
129
+ <div>
130
+ <p className="text-muted-foreground">Relations Mapped</p>
131
+ <p className="font-bold">{cycle.relations_mapped}</p>
132
+ </div>
133
+ </div>
134
+ <p className="mt-3 text-xs text-muted-foreground">Cycle ID: {cycle.id}</p>
135
+ </div>
136
+ )}
137
+ </div>
138
+ );
139
+ })}
140
+ </div>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { type NarrativeBeat, type MedicineWheelCycle, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
5
+
6
+ export default function NarrativePage() {
7
+ const [beats, setBeats] = useState<NarrativeBeat[]>([]);
8
+ const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
9
+ const [expandedBeat, setExpandedBeat] = useState<string | null>(null);
10
+
11
+ useEffect(() => {
12
+ Promise.all([fetch("/api/narrative/beats").then((r) => r.json()), fetch("/api/narrative/cycles").then((r) => r.json())])
13
+ .then(([b, c]) => { setBeats(Array.isArray(b) ? b : []); setCycles(Array.isArray(c) ? c : []); })
14
+ .catch(() => {});
15
+ }, []);
16
+
17
+ const activeCycle = cycles[0];
18
+ const dirOrder: DirectionName[] = ["east", "south", "west", "north"];
19
+ const dirIdx = activeCycle ? dirOrder.indexOf(activeCycle.current_direction as DirectionName) : 0;
20
+ const progress = ((dirIdx + 1) / 4) * 100;
21
+
22
+ return (
23
+ <div className="p-6 max-w-5xl mx-auto">
24
+ <h1 className="text-2xl font-bold mb-2">Narrative Arc</h1>
25
+ <p className="text-sm text-muted-foreground mb-6">Journey through the Four Directions</p>
26
+
27
+ {activeCycle && (
28
+ <div className="mb-8 p-6 rounded-xl border bg-card">
29
+ <div className="flex items-start justify-between mb-4">
30
+ <div>
31
+ <h2 className="text-lg font-semibold">Active Cycle</h2>
32
+ <p className="text-sm text-muted-foreground italic">&ldquo;{activeCycle.research_question}&rdquo;</p>
33
+ </div>
34
+ <span className="text-xs text-muted-foreground">Started {new Date(activeCycle.start_date).toLocaleDateString()}</span>
35
+ </div>
36
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
37
+ <div>
38
+ <p className="text-sm text-muted-foreground">Current Direction</p>
39
+ <p className="text-lg font-bold capitalize" style={{ color: DIRECTION_COLORS[activeCycle.current_direction as DirectionName] }}>
40
+ {activeCycle.current_direction}
41
+ </p>
42
+ </div>
43
+ <div>
44
+ <p className="text-sm text-muted-foreground">Ceremonies</p>
45
+ <p className="text-lg font-bold">{activeCycle.ceremonies_conducted}</p>
46
+ </div>
47
+ <div>
48
+ <p className="text-sm text-muted-foreground">Wilson Alignment</p>
49
+ <p className="text-lg font-bold">{Math.round(activeCycle.wilson_alignment * 100)}%</p>
50
+ </div>
51
+ <div>
52
+ <p className="text-sm text-muted-foreground">OCAP</p>
53
+ <p className="text-lg font-bold">{activeCycle.ocap_compliant ? "✅ Compliant" : "⚠️ Pending"}</p>
54
+ </div>
55
+ </div>
56
+ <div className="mt-4">
57
+ <div className="h-2 bg-secondary rounded-full overflow-hidden">
58
+ <div className="h-full rounded-full transition-all" style={{ width: `${progress}%`, background: `linear-gradient(to right, ${DIRECTION_COLORS.east}, ${DIRECTION_COLORS.south}, ${DIRECTION_COLORS.west}, ${DIRECTION_COLORS.north})` }} />
59
+ </div>
60
+ <div className="flex justify-between mt-1 text-xs text-muted-foreground">
61
+ {dirOrder.map((d) => <span key={d} className="capitalize">{d}</span>)}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ )}
66
+
67
+ {/* Direction Summary */}
68
+ <div className="grid grid-cols-4 gap-3 mb-6">
69
+ {dirOrder.map((dir) => {
70
+ const count = beats.filter((b) => b.direction === dir).length;
71
+ return (
72
+ <div key={dir} className="p-3 rounded-lg border text-center" style={{ borderColor: DIRECTION_COLORS[dir] + "40" }}>
73
+ <span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor: DIRECTION_COLORS[dir] }} />
74
+ <p className="text-sm capitalize font-medium">{dir}</p>
75
+ <p className="text-lg font-bold">{count}</p>
76
+ <p className="text-xs text-muted-foreground">beats</p>
77
+ </div>
78
+ );
79
+ })}
80
+ </div>
81
+
82
+ {/* Beat List */}
83
+ <h2 className="text-lg font-semibold mb-3">Narrative Beats ({beats.length})</h2>
84
+ <div className="space-y-3">
85
+ {beats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats recorded yet.</div>}
86
+ {beats.map((beat) => (
87
+ <div key={beat.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50"
88
+ style={{ borderLeftColor: DIRECTION_COLORS[beat.direction as DirectionName], borderLeftWidth: 4 }}
89
+ onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
90
+ <div className="p-4">
91
+ <div className="flex items-center justify-between">
92
+ <div>
93
+ <h3 className="font-medium">{beat.title}</h3>
94
+ <p className="text-sm text-muted-foreground">{beat.description}</p>
95
+ </div>
96
+ <div className="text-right text-xs text-muted-foreground">
97
+ <div className="capitalize">{beat.direction} · Act {beat.act}</div>
98
+ <div>{new Date(beat.timestamp).toLocaleDateString()}</div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ {expandedBeat === beat.id && (
103
+ <div className="px-4 pb-4 border-t pt-3 space-y-2">
104
+ {beat.prose && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Prose</div><p className="text-sm italic">{beat.prose}</p></div>}
105
+ {beat.learnings.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Learnings</div><ul className="text-sm space-y-1">{beat.learnings.map((l, i) => <li key={i}>• {l}</li>)}</ul></div>}
106
+ </div>
107
+ )}
108
+ </div>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import type { FormEvent } from "react";
5
+ import {
6
+ type RelationalNode,
7
+ type RelationalEdge,
8
+ type NodeType,
9
+ type DirectionName,
10
+ NODE_TYPE_COLORS,
11
+ DIRECTION_COLORS,
12
+ } from "@/lib/types";
13
+ import { toast } from "sonner";
14
+
15
+ const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
16
+ const DIRECTIONS: DirectionName[] = ["east", "south", "west", "north"];
17
+ const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚" };
18
+ const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
19
+
20
+ export default function NodesPage() {
21
+ const [nodes, setNodes] = useState<RelationalNode[]>([]);
22
+ const [edges, setEdges] = useState<RelationalEdge[]>([]);
23
+ const [loading, setLoading] = useState(true);
24
+ const [filterType, setFilterType] = useState<string>("all");
25
+ const [filterDirection, setFilterDirection] = useState<string>("all");
26
+ const [searchQuery, setSearchQuery] = useState("");
27
+ const [expandedNode, setExpandedNode] = useState<string | null>(null);
28
+ const [showAddNode, setShowAddNode] = useState(false);
29
+
30
+ const loadNodes = useCallback(async () => {
31
+ setLoading(true);
32
+ try {
33
+ const params = new URLSearchParams();
34
+ if (filterType !== "all") params.set("type", filterType);
35
+ if (filterDirection !== "all") params.set("direction", filterDirection);
36
+
37
+ const [nodesRes, edgesRes] = await Promise.all([
38
+ fetch(`/api/nodes?${params.toString()}`),
39
+ fetch("/api/edges"),
40
+ ]);
41
+ const nodesData: RelationalNode[] = await nodesRes.json();
42
+ const edgesData: RelationalEdge[] = edgesRes.ok ? await edgesRes.json() : [];
43
+ setNodes(nodesData);
44
+ setEdges(Array.isArray(edgesData) ? edgesData : []);
45
+ } catch {
46
+ toast.error("Failed to load nodes");
47
+ } finally {
48
+ setLoading(false);
49
+ }
50
+ }, [filterType, filterDirection]);
51
+
52
+ useEffect(() => { loadNodes(); }, [loadNodes]);
53
+
54
+ const filteredNodes = nodes.filter((node) => {
55
+ if (!searchQuery) return true;
56
+ const q = searchQuery.toLowerCase();
57
+ return node.name.toLowerCase().includes(q) || node.type.toLowerCase().includes(q) || (node.direction && node.direction.toLowerCase().includes(q));
58
+ });
59
+
60
+ async function addNode(e: FormEvent<HTMLFormElement>) {
61
+ e.preventDefault();
62
+ const form = new FormData(e.currentTarget);
63
+ const description = form.get("description") as string;
64
+ const body = {
65
+ name: form.get("name") as string,
66
+ type: form.get("type") as string,
67
+ direction: (form.get("direction") as string) || undefined,
68
+ metadata: description ? { description } : {},
69
+ };
70
+ try {
71
+ const res = await fetch("/api/nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
72
+ if (!res.ok) throw new Error("Failed");
73
+ toast.success("Node created");
74
+ setShowAddNode(false);
75
+ loadNodes();
76
+ } catch { toast.error("Failed to create node"); }
77
+ }
78
+
79
+ const typeCounts = NODE_TYPES.reduce((acc, t) => { acc[t] = nodes.filter((n) => n.type === t).length; return acc; }, {} as Record<NodeType, number>);
80
+
81
+ return (
82
+ <div className="p-6 max-w-7xl mx-auto">
83
+ <div className="flex items-center justify-between mb-6">
84
+ <div>
85
+ <h1 className="text-2xl font-bold">🔗 Relational Nodes</h1>
86
+ <p className="text-sm text-muted-foreground">{nodes.length} nodes in the relational web</p>
87
+ </div>
88
+ <button onClick={() => setShowAddNode(!showAddNode)} className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Add Node</button>
89
+ </div>
90
+
91
+ {showAddNode && (
92
+ <form onSubmit={addNode} className="mb-6 p-4 border rounded-lg bg-card space-y-3">
93
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
94
+ <input name="name" placeholder="Node name" required className="px-3 py-2 rounded-md border bg-background text-sm" />
95
+ <select name="type" required className="px-3 py-2 rounded-md border bg-background text-sm">
96
+ {NODE_TYPES.map((t) => <option key={t} value={t}>{NODE_TYPE_ICONS[t]} {t}</option>)}
97
+ </select>
98
+ </div>
99
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
100
+ <select name="direction" className="px-3 py-2 rounded-md border bg-background text-sm">
101
+ <option value="">No direction</option>
102
+ {DIRECTIONS.map((d) => <option key={d} value={d}>{DIRECTION_ICONS[d]} {d}</option>)}
103
+ </select>
104
+ <input name="description" placeholder="Description (optional)" className="px-3 py-2 rounded-md border bg-background text-sm" />
105
+ </div>
106
+ <div className="flex gap-2">
107
+ <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create Node</button>
108
+ <button type="button" onClick={() => setShowAddNode(false)} className="px-4 py-2 rounded-md border text-sm">Cancel</button>
109
+ </div>
110
+ </form>
111
+ )}
112
+
113
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3 mb-6">
114
+ {NODE_TYPES.map((t) => (
115
+ <button key={t} onClick={() => setFilterType(filterType === t ? "all" : t)}
116
+ className={`p-3 rounded-lg border text-center transition-all ${filterType === t ? "ring-2 ring-primary bg-secondary" : "hover:bg-secondary/50"}`}>
117
+ <div className="text-2xl mb-1">{NODE_TYPE_ICONS[t]}</div>
118
+ <div className="text-sm font-medium capitalize">{t}</div>
119
+ <div className="text-lg font-bold" style={{ color: NODE_TYPE_COLORS[t] }}>{typeCounts[t]}</div>
120
+ </button>
121
+ ))}
122
+ </div>
123
+
124
+ <div className="flex flex-wrap gap-3 mb-6">
125
+ <input type="text" placeholder="Search nodes..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="px-3 py-2 rounded-md border bg-background text-sm flex-1 min-w-[200px]" />
126
+ <select value={filterDirection} onChange={(e) => setFilterDirection(e.target.value)} className="px-3 py-2 rounded-md border bg-background text-sm">
127
+ <option value="all">All Directions</option>
128
+ {DIRECTIONS.map((d) => <option key={d} value={d}>{DIRECTION_ICONS[d]} {d}</option>)}
129
+ </select>
130
+ {(filterType !== "all" || filterDirection !== "all" || searchQuery) && (
131
+ <button onClick={() => { setFilterType("all"); setFilterDirection("all"); setSearchQuery(""); }} className="px-3 py-2 rounded-md border text-sm text-muted-foreground hover:text-foreground">Clear filters</button>
132
+ )}
133
+ </div>
134
+
135
+ {loading ? (
136
+ <div className="text-center py-12 text-muted-foreground">Loading nodes...</div>
137
+ ) : filteredNodes.length === 0 ? (
138
+ <div className="text-center py-12 text-muted-foreground">
139
+ <p>No nodes found</p>
140
+ {(filterType !== "all" || filterDirection !== "all" || searchQuery) && <p className="text-sm mt-2">Try adjusting your filters</p>}
141
+ </div>
142
+ ) : (
143
+ <div className="space-y-2">
144
+ {filteredNodes.map((node) => (
145
+ <div key={node.id} className={`border rounded-lg bg-card overflow-hidden transition-all ${expandedNode === node.id ? "ring-2 ring-primary" : ""}`}>
146
+ <button
147
+ onClick={() => setExpandedNode(expandedNode === node.id ? null : node.id)}
148
+ className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-secondary/30"
149
+ >
150
+ <span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ backgroundColor: NODE_TYPE_COLORS[node.type] + "30" }}>
151
+ {NODE_TYPE_ICONS[node.type]}
152
+ </span>
153
+ <div className="flex-1 min-w-0">
154
+ <div className="font-medium truncate">{node.name}</div>
155
+ <div className="text-sm text-muted-foreground flex gap-2">
156
+ <span className="capitalize">{node.type}</span>
157
+ {node.direction && (
158
+ <>
159
+ <span>·</span>
160
+ <span className="capitalize" style={{ color: DIRECTION_COLORS[node.direction] }}>{DIRECTION_ICONS[node.direction]} {node.direction}</span>
161
+ </>
162
+ )}
163
+ </div>
164
+ </div>
165
+ <span className="text-muted-foreground">{expandedNode === node.id ? "▲" : "▼"}</span>
166
+ </button>
167
+
168
+ {expandedNode === node.id && (
169
+ <div className="border-t p-4 space-y-3">
170
+ <div className="grid grid-cols-2 gap-4 text-sm">
171
+ <div><span className="text-muted-foreground">Type:</span> <span className="capitalize">{node.type}</span></div>
172
+ {node.direction && <div><span className="text-muted-foreground">Direction:</span> <span className="capitalize">{node.direction}</span></div>}
173
+ <div><span className="text-muted-foreground">Created:</span> {new Date(node.created_at).toLocaleDateString()}</div>
174
+ <div><span className="text-muted-foreground">ID:</span> <code className="text-xs">{node.id.slice(0, 8)}…</code></div>
175
+ </div>
176
+ <div>
177
+ <h4 className="text-sm font-medium mb-2">Connected Edges ({edges.filter((e) => e.from_id === node.id || e.to_id === node.id).length})</h4>
178
+ {edges.filter((e) => e.from_id === node.id || e.to_id === node.id).map((e) => {
179
+ const otherId = e.from_id === node.id ? e.to_id : e.from_id;
180
+ const other = nodes.find((n) => n.id === otherId);
181
+ return (
182
+ <div key={e.id} className="py-1 text-sm border-b border-border/50 flex items-center gap-2">
183
+ <span className="text-muted-foreground">{e.relationship_type}</span>
184
+ → {other?.name || "?"}
185
+ <span className="text-xs text-muted-foreground">({Math.round(e.strength * 100)}%)</span>
186
+ {e.ceremony_honored && <span className="text-green-500">✓</span>}
187
+ </div>
188
+ );
189
+ })}
190
+ </div>
191
+ </div>
192
+ )}
193
+ </div>
194
+ ))}
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }