@medicine-wheel/app 0.4.13 → 0.5.1

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.
@@ -2,10 +2,13 @@
2
2
 
3
3
  import { useEffect, useState } from "react";
4
4
  import type { FormEvent } from "react";
5
- import { type MedicineWheelCycle, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
5
+ import { type MedicineWheelCycle, type DirectionName } from "@/lib/types";
6
6
  import { extractCycles } from "@/lib/cycle-response";
7
7
  import { toast } from "sonner";
8
8
 
9
+ const dirVar = (d: string) => `var(--mw-${d})`;
10
+ const dirInk = (d: string) => `var(--mw-${d}-ink)`;
11
+
9
12
  export default function CyclesPage() {
10
13
  const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
11
14
  const [showForm, setShowForm] = useState(false);
@@ -28,7 +31,7 @@ export default function CyclesPage() {
28
31
  setShowForm(false);
29
32
  const data = await fetch("/api/narrative/cycles").then((r) => r.json());
30
33
  setCycles(extractCycles(data));
31
- } else { toast.error("Failed"); }
34
+ } else { toast.error("Could not create the cycle — try again"); }
32
35
  }
33
36
 
34
37
  const dirOrder: DirectionName[] = ["east", "south", "west", "north"];
@@ -37,18 +40,18 @@ export default function CyclesPage() {
37
40
  <div className="p-6 max-w-5xl mx-auto">
38
41
  <div className="flex items-center justify-between mb-6">
39
42
  <div>
40
- <h1 className="text-2xl font-bold">Medicine Wheel Cycles</h1>
43
+ <h1 className="mw-h1">Cycles</h1>
41
44
  <p className="text-sm text-muted-foreground">{cycles.length} research cycles</p>
42
45
  </div>
43
- <button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ New Cycle</button>
46
+ <button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">New cycle</button>
44
47
  </div>
45
48
 
46
49
  {showForm && (
47
50
  <form onSubmit={createCycle} className="mb-6 p-4 border rounded-lg bg-card">
48
51
  <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" />
49
52
  <div className="flex gap-2">
50
- <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create Cycle</button>
51
- <button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-md border text-sm">Cancel</button>
53
+ <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:opacity-90">Create cycle</button>
54
+ <button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-md border text-sm hover:bg-secondary/60">Cancel</button>
52
55
  </div>
53
56
  </form>
54
57
  )}
@@ -78,7 +81,7 @@ export default function CyclesPage() {
78
81
  return (
79
82
  <path key={dir}
80
83
  d={`M 24 24 L ${x1} ${y1} A 20 20 0 0 1 ${x2} ${y2} Z`}
81
- fill={DIRECTION_COLORS[dir]}
84
+ fill={dirVar(dir)}
82
85
  opacity={completed ? 0.9 : 0.2}
83
86
  />
84
87
  );
@@ -89,7 +92,7 @@ export default function CyclesPage() {
89
92
  <div className="flex-1 min-w-0">
90
93
  <h3 className="font-semibold mb-1">{cycle.research_question}</h3>
91
94
  <div className="flex items-center gap-4 text-sm text-muted-foreground">
92
- <span className="capitalize" style={{ color: DIRECTION_COLORS[cycle.current_direction as DirectionName] }}>
95
+ <span className="capitalize" style={{ color: dirInk(cycle.current_direction) }}>
93
96
  ● {cycle.current_direction}
94
97
  </span>
95
98
  <span>{cycle.beats.length} beats</span>
@@ -101,7 +104,7 @@ export default function CyclesPage() {
101
104
  <div className="mt-3 h-2 bg-secondary rounded-full overflow-hidden">
102
105
  <div className="h-full rounded-full transition-all" style={{
103
106
  width: `${progress}%`,
104
- background: `linear-gradient(to right, ${DIRECTION_COLORS.east}, ${DIRECTION_COLORS.south}, ${DIRECTION_COLORS.west}, ${DIRECTION_COLORS.north})`,
107
+ background: "linear-gradient(to right, var(--mw-east), var(--mw-south), var(--mw-west), var(--mw-north))",
105
108
  }} />
106
109
  </div>
107
110
  </div>
@@ -114,7 +117,7 @@ export default function CyclesPage() {
114
117
  {dirOrder.map((dir) => {
115
118
  const active = dirOrder.indexOf(dir) <= dirIdx;
116
119
  return (
117
- <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 }}>
120
+ <div key={dir} className="text-center p-2 rounded-md border" style={{ borderColor: active ? dirVar(dir) : "var(--color-border)", opacity: active ? 1 : 0.4 }}>
118
121
  <p className="text-xs capitalize font-medium">{dir}</p>
119
122
  <p className="text-sm">{active ? "✓" : "○"}</p>
120
123
  </div>
@@ -0,0 +1,9 @@
1
+ import type { Metadata } from "next";
2
+
3
+ export const metadata: Metadata = {
4
+ title: { default: "Narrative", template: "%s · Medicine Wheel" },
5
+ };
6
+
7
+ export default function NarrativeLayout({ children }: { children: React.ReactNode }) {
8
+ return children;
9
+ }
@@ -1,10 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { type NarrativeBeat, type MedicineWheelCycle, DIRECTION_COLORS, type DirectionName } from "@/lib/types";
4
+ import { type NarrativeBeat, type MedicineWheelCycle, type DirectionName } from "@/lib/types";
5
5
  import { extractCycles } from "@/lib/cycle-response";
6
6
  import { relativeTime, absoluteTime } from "@/lib/format-time";
7
7
 
8
+ const dirVar = (d: string) => `var(--mw-${d})`;
9
+ const dirInk = (d: string) => `var(--mw-${d}-ink)`;
10
+
8
11
  export default function NarrativePage() {
9
12
  const [beats, setBeats] = useState<NarrativeBeat[]>([]);
10
13
  const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
@@ -25,7 +28,7 @@ export default function NarrativePage() {
25
28
 
26
29
  return (
27
30
  <div className="p-6 max-w-5xl mx-auto">
28
- <h1 className="text-2xl font-bold mb-2">Narrative Arc</h1>
31
+ <h1 className="mw-h1 mb-2">Narrative arc</h1>
29
32
  <p className="text-sm text-muted-foreground mb-6">Journey through the Four Directions</p>
30
33
 
31
34
  {activeCycle && (
@@ -40,7 +43,7 @@ export default function NarrativePage() {
40
43
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
41
44
  <div>
42
45
  <p className="text-sm text-muted-foreground">Current Direction</p>
43
- <p className="text-lg font-bold capitalize" style={{ color: DIRECTION_COLORS[activeCycle.current_direction as DirectionName] }}>
46
+ <p className="text-lg font-bold capitalize" style={{ color: dirInk(activeCycle.current_direction) }}>
44
47
  {activeCycle.current_direction}
45
48
  </p>
46
49
  </div>
@@ -59,7 +62,7 @@ export default function NarrativePage() {
59
62
  </div>
60
63
  <div className="mt-4">
61
64
  <div className="h-2 bg-secondary rounded-full overflow-hidden">
62
- <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})` }} />
65
+ <div className="h-full rounded-full transition-all" style={{ width: `${progress}%`, background: "linear-gradient(to right, var(--mw-east), var(--mw-south), var(--mw-west), var(--mw-north))" }} />
63
66
  </div>
64
67
  <div className="flex justify-between mt-1 text-xs text-muted-foreground">
65
68
  {dirOrder.map((d) => <span key={d} className="capitalize">{d}</span>)}
@@ -73,8 +76,8 @@ export default function NarrativePage() {
73
76
  {dirOrder.map((dir) => {
74
77
  const count = beats.filter((b) => b.direction === dir).length;
75
78
  return (
76
- <div key={dir} className="p-3 rounded-lg border text-center" style={{ borderColor: DIRECTION_COLORS[dir] + "40" }}>
77
- <span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor: DIRECTION_COLORS[dir] }} />
79
+ <div key={dir} className="p-3 rounded-lg border text-center" style={{ borderColor: `var(--mw-${dir}-border)` }}>
80
+ <span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor: dirVar(dir) }} />
78
81
  <p className="text-sm capitalize font-medium">{dir}</p>
79
82
  <p className="text-lg font-bold">{count}</p>
80
83
  <p className="text-xs text-muted-foreground">beats</p>
@@ -87,10 +90,10 @@ export default function NarrativePage() {
87
90
  <h2 className="text-lg font-semibold mb-3">Narrative Beats ({beats.length})</h2>
88
91
  <div className="space-y-3">
89
92
  {loading && <div className="text-center py-8 text-muted-foreground">Loading narrative beats…</div>}
90
- {!loading && beats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats recorded yet.</div>}
93
+ {!loading && beats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats yet. Record the first one on the Beats page to begin the arc.</div>}
91
94
  {!loading && beats.map((beat) => (
92
95
  <div key={beat.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50"
93
- style={{ borderLeftColor: DIRECTION_COLORS[beat.direction as DirectionName], borderLeftWidth: 4 }}
96
+ style={{ borderLeftColor: dirVar(beat.direction), borderLeftWidth: 4 }}
94
97
  onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
95
98
  <div className="p-4">
96
99
  <div className="flex items-center justify-between">
@@ -1,25 +1,40 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState, useCallback } from "react";
4
- import type { FormEvent } from "react";
4
+ import type { SubmitEvent } from "react";
5
+ import Link from "next/link";
5
6
  import {
6
7
  type RelationalNode,
7
8
  type RelationalEdge,
8
9
  type NodeType,
9
10
  type DirectionName,
10
- NODE_TYPE_COLORS,
11
- DIRECTION_COLORS,
12
11
  } from "@/lib/types";
13
12
  import { relativeTime, absoluteTime } from "@/lib/format-time";
14
13
  import { toast } from "sonner";
15
14
 
15
+ // Storage returns description at the top level; legacy nodes carried it in metadata.
16
+ type NodeRecord = RelationalNode & { description?: string };
17
+
16
18
  const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
17
19
  const DIRECTIONS: DirectionName[] = ["east", "south", "west", "north"];
18
20
  const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚" };
19
21
  const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
20
22
 
23
+ function describeNode(node: NodeRecord): string {
24
+ return node.description || (typeof node.metadata?.description === "string" ? node.metadata.description : "");
25
+ }
26
+
27
+ function DirectionChip({ direction }: { direction?: DirectionName }) {
28
+ if (!direction) return <span className="mw-badge">no direction</span>;
29
+ return (
30
+ <span className={`mw-badge mw-badge--${direction}`}>
31
+ {DIRECTION_ICONS[direction]} {direction}
32
+ </span>
33
+ );
34
+ }
35
+
21
36
  export default function NodesPage() {
22
- const [nodes, setNodes] = useState<RelationalNode[]>([]);
37
+ const [nodes, setNodes] = useState<NodeRecord[]>([]);
23
38
  const [edges, setEdges] = useState<RelationalEdge[]>([]);
24
39
  const [loading, setLoading] = useState(true);
25
40
  const [filterType, setFilterType] = useState<string>("all");
@@ -28,6 +43,9 @@ export default function NodesPage() {
28
43
  const [expandedNode, setExpandedNode] = useState<string | null>(null);
29
44
  const [showAddNode, setShowAddNode] = useState(false);
30
45
  const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
46
+ const [editingNode, setEditingNode] = useState<string | null>(null);
47
+ const [deletingNode, setDeletingNode] = useState<NodeRecord | null>(null);
48
+ const [busy, setBusy] = useState(false);
31
49
 
32
50
  const loadNodes = useCallback(async () => {
33
51
  setLoading(true);
@@ -42,13 +60,13 @@ export default function NodesPage() {
42
60
  ]);
43
61
  const nodesResponse = await nodesRes.json();
44
62
  const edgesData: RelationalEdge[] = edgesRes.ok ? await edgesRes.json() : [];
45
-
63
+
46
64
  // API returns { nodes: [...], provider: '...', count: N }
47
- const nodesData: RelationalNode[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
65
+ const nodesData: NodeRecord[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
48
66
  setNodes(nodesData);
49
67
  setEdges(Array.isArray(edgesData) ? edgesData : []);
50
68
  } catch {
51
- toast.error("Failed to load nodes");
69
+ toast.error("Nodes did not load — check the connection and try again");
52
70
  } finally {
53
71
  setLoading(false);
54
72
  }
@@ -79,23 +97,74 @@ export default function NodesPage() {
79
97
  return node.name.toLowerCase().includes(q) || node.type.toLowerCase().includes(q) || (node.direction && node.direction.toLowerCase().includes(q));
80
98
  });
81
99
 
82
- async function addNode(e: FormEvent<HTMLFormElement>) {
100
+ function relationsOf(nodeId: string): RelationalEdge[] {
101
+ return edges.filter((e) => e.from_id === nodeId || e.to_id === nodeId);
102
+ }
103
+
104
+ async function addNode(e: SubmitEvent<HTMLFormElement>) {
83
105
  e.preventDefault();
84
106
  const form = new FormData(e.currentTarget);
85
- const description = form.get("description") as string;
86
107
  const body = {
87
108
  name: form.get("name") as string,
88
109
  type: form.get("type") as string,
89
110
  direction: (form.get("direction") as string) || undefined,
90
- metadata: description ? { description } : {},
111
+ description: (form.get("description") as string) || undefined,
91
112
  };
113
+ setBusy(true);
92
114
  try {
93
115
  const res = await fetch("/api/nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
94
- if (!res.ok) throw new Error("Failed");
95
- toast.success("Node created");
116
+ const data = await res.json();
117
+ if (!res.ok) throw new Error(data.error || "Node was not created");
118
+ toast.success(`Node "${data.node.name}" joined the wheel`);
96
119
  setShowAddNode(false);
97
120
  loadNodes();
98
- } catch { toast.error("Failed to create node"); }
121
+ } catch (err) {
122
+ toast.error(err instanceof Error ? err.message : "Node was not created");
123
+ } finally {
124
+ setBusy(false);
125
+ }
126
+ }
127
+
128
+ async function saveNode(e: SubmitEvent<HTMLFormElement>, node: NodeRecord) {
129
+ e.preventDefault();
130
+ const form = new FormData(e.currentTarget);
131
+ const direction = form.get("direction") as string;
132
+ const body = {
133
+ name: form.get("name") as string,
134
+ type: form.get("type") as string,
135
+ description: (form.get("description") as string) ?? "",
136
+ direction: direction === "" ? null : direction,
137
+ };
138
+ setBusy(true);
139
+ try {
140
+ const res = await fetch(`/api/nodes/${node.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
141
+ const data = await res.json();
142
+ if (!res.ok) throw new Error(data.issues?.join("; ") || data.error || "Changes were not saved");
143
+ toast.success(`Changes to "${data.node.name}" saved`);
144
+ setEditingNode(null);
145
+ loadNodes();
146
+ } catch (err) {
147
+ toast.error(err instanceof Error ? err.message : "Changes were not saved");
148
+ } finally {
149
+ setBusy(false);
150
+ }
151
+ }
152
+
153
+ async function deleteNode(node: NodeRecord) {
154
+ setBusy(true);
155
+ try {
156
+ const res = await fetch(`/api/nodes/${node.id}`, { method: "DELETE" });
157
+ const data = await res.json();
158
+ if (!res.ok) throw new Error(data.error || "Node was not deleted");
159
+ toast.success(`Node "${node.name}" released from the wheel`);
160
+ setDeletingNode(null);
161
+ setExpandedNode(null);
162
+ loadNodes();
163
+ } catch (err) {
164
+ toast.error(err instanceof Error ? err.message : "Node was not deleted");
165
+ } finally {
166
+ setBusy(false);
167
+ }
99
168
  }
100
169
 
101
170
  const typeCounts = NODE_TYPES.reduce((acc, t) => { acc[t] = nodes.filter((n) => n.type === t).length; return acc; }, {} as Record<NodeType, number>);
@@ -104,30 +173,44 @@ export default function NodesPage() {
104
173
  <div className="p-6 max-w-7xl mx-auto">
105
174
  <div className="flex items-center justify-between mb-6">
106
175
  <div>
107
- <h1 className="text-2xl font-bold">🔗 Relational Nodes</h1>
176
+ <h1 className="text-2xl font-bold">Nodes</h1>
108
177
  <p className="text-sm text-muted-foreground">{nodes.length} nodes in the relational web</p>
109
178
  </div>
110
- <button onClick={() => setShowAddNode(!showAddNode)} className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Add Node</button>
179
+ <button onClick={() => setShowAddNode(!showAddNode)} className="mw-btn mw-btn--primary">
180
+ {showAddNode ? "Close form" : "Create node"}
181
+ </button>
111
182
  </div>
112
183
 
113
184
  {showAddNode && (
114
185
  <form onSubmit={addNode} className="mb-6 p-4 border rounded-lg bg-card space-y-3">
115
186
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
116
- <input name="name" placeholder="Node name" required className="px-3 py-2 rounded-md border bg-background text-sm" />
117
- <select name="type" required className="px-3 py-2 rounded-md border bg-background text-sm">
118
- {NODE_TYPES.map((t) => <option key={t} value={t}>{NODE_TYPE_ICONS[t]} {t}</option>)}
119
- </select>
187
+ <label className="text-sm space-y-1">
188
+ <span className="text-muted-foreground">Name</span>
189
+ <input name="name" placeholder="Who or what joins the wheel?" required className="mw-input" />
190
+ </label>
191
+ <label className="text-sm space-y-1">
192
+ <span className="text-muted-foreground">Type</span>
193
+ <select name="type" required className="mw-input">
194
+ {NODE_TYPES.map((t) => <option key={t} value={t}>{NODE_TYPE_ICONS[t]} {t}</option>)}
195
+ </select>
196
+ </label>
120
197
  </div>
121
198
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
122
- <select name="direction" className="px-3 py-2 rounded-md border bg-background text-sm">
123
- <option value="">No direction</option>
124
- {DIRECTIONS.map((d) => <option key={d} value={d}>{DIRECTION_ICONS[d]} {d}</option>)}
125
- </select>
126
- <input name="description" placeholder="Description (optional)" className="px-3 py-2 rounded-md border bg-background text-sm" />
199
+ <label className="text-sm space-y-1">
200
+ <span className="text-muted-foreground">Direction</span>
201
+ <select name="direction" className="mw-input">
202
+ <option value="">No direction</option>
203
+ {DIRECTIONS.map((d) => <option key={d} value={d}>{DIRECTION_ICONS[d]} {d}</option>)}
204
+ </select>
205
+ </label>
206
+ <label className="text-sm space-y-1">
207
+ <span className="text-muted-foreground">Description</span>
208
+ <input name="description" placeholder="What this node carries (optional)" className="mw-input" />
209
+ </label>
127
210
  </div>
128
211
  <div className="flex gap-2">
129
- <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create Node</button>
130
- <button type="button" onClick={() => setShowAddNode(false)} className="px-4 py-2 rounded-md border text-sm">Cancel</button>
212
+ <button type="submit" disabled={busy} className="mw-btn mw-btn--primary">Create node</button>
213
+ <button type="button" onClick={() => setShowAddNode(false)} className="mw-btn mw-btn--ghost">Cancel</button>
131
214
  </div>
132
215
  </form>
133
216
  )}
@@ -135,89 +218,180 @@ export default function NodesPage() {
135
218
  <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3 mb-6">
136
219
  {NODE_TYPES.map((t) => (
137
220
  <button key={t} onClick={() => setFilterType(filterType === t ? "all" : t)}
221
+ aria-pressed={filterType === t}
138
222
  className={`p-3 rounded-lg border text-center transition-all ${filterType === t ? "ring-2 ring-primary bg-secondary" : "hover:bg-secondary/50"}`}>
139
223
  <div className="text-2xl mb-1">{NODE_TYPE_ICONS[t]}</div>
140
224
  <div className="text-sm font-medium capitalize">{t}</div>
141
- <div className="text-lg font-bold" style={{ color: NODE_TYPE_COLORS[t] }}>{typeCounts[t]}</div>
225
+ <div className="text-lg font-bold" style={{ color: `var(--mw-node-${t})` }}>{typeCounts[t]}</div>
142
226
  </button>
143
227
  ))}
144
228
  </div>
145
229
 
146
230
  <div className="flex flex-wrap gap-3 mb-6">
147
- <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]" />
148
- <select value={filterDirection} onChange={(e) => setFilterDirection(e.target.value)} className="px-3 py-2 rounded-md border bg-background text-sm">
149
- <option value="all">All Directions</option>
231
+ <input type="text" placeholder="Search nodes…" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="mw-input flex-1 min-w-[200px]" style={{ width: "auto" }} />
232
+ <select value={filterDirection} onChange={(e) => setFilterDirection(e.target.value)} className="mw-input" style={{ width: "auto" }}>
233
+ <option value="all">All directions</option>
150
234
  {DIRECTIONS.map((d) => <option key={d} value={d}>{DIRECTION_ICONS[d]} {d}</option>)}
151
235
  </select>
152
236
  {(filterType !== "all" || filterDirection !== "all" || searchQuery) && (
153
- <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>
237
+ <button onClick={() => { setFilterType("all"); setFilterDirection("all"); setSearchQuery(""); }} className="mw-btn mw-btn--ghost">Clear filters</button>
154
238
  )}
155
239
  </div>
156
240
 
157
241
  {loading ? (
158
- <div className="text-center py-12 text-muted-foreground">Loading nodes...</div>
242
+ <div className="text-center py-12 text-muted-foreground">Loading nodes…</div>
159
243
  ) : filteredNodes.length === 0 ? (
160
244
  <div className="text-center py-12 text-muted-foreground">
161
- <p>No nodes found</p>
162
- {(filterType !== "all" || filterDirection !== "all" || searchQuery) && <p className="text-sm mt-2">Try adjusting your filters</p>}
245
+ {nodes.length === 0 ? (
246
+ <>
247
+ <p>The wheel holds no nodes yet.</p>
248
+ <button onClick={() => setShowAddNode(true)} className="mw-btn mw-btn--primary mt-4">Create the first node</button>
249
+ </>
250
+ ) : (
251
+ <>
252
+ <p>No nodes match these filters.</p>
253
+ <p className="text-sm mt-2">Loosen the search or clear the filters to see the full circle.</p>
254
+ </>
255
+ )}
163
256
  </div>
164
257
  ) : (
165
258
  <div className="space-y-2">
166
- {filteredNodes.map((node) => (
167
- <div
168
- key={node.id}
169
- id={`node-${node.id}`}
170
- className={`border rounded-lg bg-card overflow-hidden transition-all ${expandedNode === node.id ? "ring-2 ring-primary" : ""}`}
171
- >
172
- <button
173
- onClick={() => setExpandedNode(expandedNode === node.id ? null : node.id)}
174
- className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-secondary/30"
259
+ {filteredNodes.map((node) => {
260
+ const nodeRelations = relationsOf(node.id);
261
+ const isExpanded = expandedNode === node.id;
262
+ const isEditing = editingNode === node.id;
263
+ return (
264
+ <div
265
+ key={node.id}
266
+ id={`node-${node.id}`}
267
+ className={`border rounded-lg bg-card overflow-hidden transition-all ${isExpanded ? "ring-2 ring-primary" : ""}`}
175
268
  >
176
- <span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ backgroundColor: NODE_TYPE_COLORS[node.type] + "30" }}>
177
- {NODE_TYPE_ICONS[node.type]}
178
- </span>
179
- <div className="flex-1 min-w-0">
180
- <div className="font-medium truncate">{node.name}</div>
181
- <div className="text-sm text-muted-foreground flex gap-2">
182
- <span className="capitalize">{node.type}</span>
183
- {node.direction && (
269
+ <button
270
+ onClick={() => { setExpandedNode(isExpanded ? null : node.id); if (!isExpanded) setEditingNode(null); }}
271
+ aria-expanded={isExpanded}
272
+ className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-secondary/30"
273
+ >
274
+ <span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ backgroundColor: `color-mix(in srgb, var(--mw-node-${node.type}) 20%, transparent)` }}>
275
+ {NODE_TYPE_ICONS[node.type]}
276
+ </span>
277
+ <div className="flex-1 min-w-0">
278
+ <div className="font-medium truncate">{node.name}</div>
279
+ <div className="text-sm text-muted-foreground flex items-center gap-2">
280
+ <span className="capitalize">{node.type}</span>
281
+ <DirectionChip direction={node.direction} />
282
+ {nodeRelations.length > 0 && <span>{nodeRelations.length} relation{nodeRelations.length === 1 ? "" : "s"}</span>}
283
+ </div>
284
+ </div>
285
+ <span className="text-muted-foreground" aria-hidden>{isExpanded ? "▲" : "▼"}</span>
286
+ </button>
287
+
288
+ {isExpanded && (
289
+ <div className="border-t p-4 space-y-3">
290
+ {isEditing ? (
291
+ <form onSubmit={(e) => saveNode(e, node)} className="space-y-3">
292
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
293
+ <label className="text-sm space-y-1">
294
+ <span className="text-muted-foreground">Name</span>
295
+ <input name="name" defaultValue={node.name} required className="mw-input" />
296
+ </label>
297
+ <label className="text-sm space-y-1">
298
+ <span className="text-muted-foreground">Type</span>
299
+ <select name="type" defaultValue={node.type} className="mw-input">
300
+ {NODE_TYPES.map((t) => <option key={t} value={t}>{NODE_TYPE_ICONS[t]} {t}</option>)}
301
+ </select>
302
+ </label>
303
+ </div>
304
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
305
+ <label className="text-sm space-y-1">
306
+ <span className="text-muted-foreground">Direction</span>
307
+ <select name="direction" defaultValue={node.direction ?? ""} className="mw-input">
308
+ <option value="">No direction</option>
309
+ {DIRECTIONS.map((d) => <option key={d} value={d}>{DIRECTION_ICONS[d]} {d}</option>)}
310
+ </select>
311
+ </label>
312
+ <label className="text-sm space-y-1">
313
+ <span className="text-muted-foreground">Description</span>
314
+ <input name="description" defaultValue={describeNode(node)} placeholder="What this node carries" className="mw-input" />
315
+ </label>
316
+ </div>
317
+ <div className="flex gap-2">
318
+ <button type="submit" disabled={busy} className="mw-btn mw-btn--primary">Save changes</button>
319
+ <button type="button" onClick={() => setEditingNode(null)} className="mw-btn mw-btn--ghost">Cancel</button>
320
+ </div>
321
+ </form>
322
+ ) : (
184
323
  <>
185
- <span>·</span>
186
- <span className="capitalize" style={{ color: DIRECTION_COLORS[node.direction] }}>{DIRECTION_ICONS[node.direction]} {node.direction}</span>
324
+ {describeNode(node) && <p className="text-sm">{describeNode(node)}</p>}
325
+ <div className="grid grid-cols-2 gap-4 text-sm">
326
+ <div><span className="text-muted-foreground">Type:</span> <span className="capitalize">{node.type}</span></div>
327
+ <div><span className="text-muted-foreground">Direction:</span> <DirectionChip direction={node.direction} /></div>
328
+ <div><span className="text-muted-foreground">Created:</span> <span title={absoluteTime(node.created_at)}>{relativeTime(node.created_at)}</span></div>
329
+ <div><span className="text-muted-foreground">ID:</span> <code className="text-xs">{node.id.slice(0, 8)}…</code></div>
330
+ </div>
331
+ <div>
332
+ <h4 className="text-sm font-medium mb-2">Relations ({nodeRelations.length})</h4>
333
+ {nodeRelations.length === 0 ? (
334
+ <p className="text-sm text-muted-foreground">
335
+ No relations yet — <Link href="/relations" className="underline hover:text-foreground">weave one on the relations page</Link>.
336
+ </p>
337
+ ) : (
338
+ nodeRelations.map((e) => {
339
+ const otherId = e.from_id === node.id ? e.to_id : e.from_id;
340
+ const other = nodes.find((n) => n.id === otherId);
341
+ return (
342
+ <div key={`${e.from_id}:${e.to_id}`} className="py-1 text-sm border-b border-border/50 flex items-center gap-2">
343
+ <span className="text-muted-foreground">{e.relationship_type}</span>
344
+ <span aria-hidden>{e.from_id === node.id ? "→" : "←"}</span>
345
+ {other?.name || "?"}
346
+ <span className="text-xs text-muted-foreground">({Math.round(e.strength * 100)}%)</span>
347
+ {e.ceremony_honored && <span style={{ color: "var(--mw-ceremony)" }} title="Ceremony honored">✦</span>}
348
+ </div>
349
+ );
350
+ })
351
+ )}
352
+ </div>
353
+ <div className="flex gap-2 pt-1">
354
+ <button onClick={() => setEditingNode(node.id)} className="mw-btn mw-btn--secondary">Edit node</button>
355
+ <Link href={`/graph?node=${node.id}`} className="mw-btn mw-btn--ghost">See on the wheel</Link>
356
+ <button onClick={() => setDeletingNode(node)} className="mw-btn mw-btn--danger">Delete node</button>
357
+ </div>
187
358
  </>
188
359
  )}
189
360
  </div>
361
+ )}
362
+ </div>
363
+ );
364
+ })}
365
+ </div>
366
+ )}
367
+
368
+ {deletingNode && (
369
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="dialog" aria-modal="true" aria-labelledby="delete-node-title">
370
+ <div className="w-full max-w-md rounded-lg border bg-card p-6 space-y-4">
371
+ <h2 id="delete-node-title" className="text-lg font-semibold">Delete “{deletingNode.name}”?</h2>
372
+ {relationsOf(deletingNode.id).length > 0 ? (
373
+ <>
374
+ <p className="text-sm">
375
+ This node holds {relationsOf(deletingNode.id).length} relation{relationsOf(deletingNode.id).length === 1 ? "" : "s"} — release them first.
376
+ Relations are severed consciously, never cascaded.
377
+ </p>
378
+ <div className="flex gap-2 justify-end">
379
+ <button onClick={() => setDeletingNode(null)} className="mw-btn mw-btn--ghost">Keep the node</button>
380
+ <Link href={`/relations?node=${deletingNode.id}`} className="mw-btn mw-btn--primary">Review its relations</Link>
190
381
  </div>
191
- <span className="text-muted-foreground">{expandedNode === node.id ? "▲" : "▼"}</span>
192
- </button>
193
-
194
- {expandedNode === node.id && (
195
- <div className="border-t p-4 space-y-3">
196
- <div className="grid grid-cols-2 gap-4 text-sm">
197
- <div><span className="text-muted-foreground">Type:</span> <span className="capitalize">{node.type}</span></div>
198
- {node.direction && <div><span className="text-muted-foreground">Direction:</span> <span className="capitalize">{node.direction}</span></div>}
199
- <div><span className="text-muted-foreground">Created:</span> <span title={absoluteTime(node.created_at)}>{relativeTime(node.created_at)}</span></div>
200
- <div><span className="text-muted-foreground">ID:</span> <code className="text-xs">{node.id.slice(0, 8)}…</code></div>
201
- </div>
202
- <div>
203
- <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>
204
- {edges.filter((e) => e.from_id === node.id || e.to_id === node.id).map((e) => {
205
- const otherId = e.from_id === node.id ? e.to_id : e.from_id;
206
- const other = nodes.find((n) => n.id === otherId);
207
- return (
208
- <div key={e.id} className="py-1 text-sm border-b border-border/50 flex items-center gap-2">
209
- <span className="text-muted-foreground">{e.relationship_type}</span>
210
- → {other?.name || "?"}
211
- <span className="text-xs text-muted-foreground">({Math.round(e.strength * 100)}%)</span>
212
- {e.ceremony_honored && <span className="text-green-500">✓</span>}
213
- </div>
214
- );
215
- })}
216
- </div>
382
+ </>
383
+ ) : (
384
+ <>
385
+ <p className="text-sm">
386
+ This node holds no relations. Deleting releases it from the wheel — this cannot be undone.
387
+ </p>
388
+ <div className="flex gap-2 justify-end">
389
+ <button onClick={() => setDeletingNode(null)} className="mw-btn mw-btn--ghost">Keep the node</button>
390
+ <button onClick={() => deleteNode(deletingNode)} disabled={busy} className="mw-btn mw-btn--danger border border-current">Delete node</button>
217
391
  </div>
218
- )}
219
- </div>
220
- ))}
392
+ </>
393
+ )}
394
+ </div>
221
395
  </div>
222
396
  )}
223
397
  </div>