@medicine-wheel/app 0.4.12 โ†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,200 +1,381 @@
1
1
  "use client";
2
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";
3
+ import { useEffect, useMemo, useState, useCallback } from "react";
4
+ import type { SubmitEvent } from "react";
5
+ import Link from "next/link";
6
+ import {
7
+ type RelationalNode,
8
+ type RelationalEdge,
9
+ type DirectionName,
10
+ } from "@/lib/types";
6
11
  import { relativeTime, absoluteTime } from "@/lib/format-time";
7
12
  import { toast } from "sonner";
8
13
 
9
- interface GraphNode extends RelationalNode {
10
- x: number;
11
- y: number;
14
+ type NodeRecord = RelationalNode & { description?: string };
15
+
16
+ const DIRECTION_ICONS: Record<DirectionName, string> = { east: "๐ŸŒ…", south: "๐ŸŒž", west: "๐ŸŒ„", north: "โ„๏ธ" };
17
+
18
+ function edgeKey(edge: Pick<RelationalEdge, "from_id" | "to_id">): string {
19
+ return `${edge.from_id}:${edge.to_id}`;
20
+ }
21
+
22
+ function NodeChip({ node, id }: { node?: NodeRecord; id: string }) {
23
+ if (!node) {
24
+ return <span className="mw-badge" title={id}>missing node</span>;
25
+ }
26
+ const directionClass = node.direction ? ` mw-badge--${node.direction}` : "";
27
+ return (
28
+ <Link
29
+ href={`/graph?node=${node.id}`}
30
+ className={`mw-badge${directionClass} hover:brightness-125 transition-all`}
31
+ title={node.direction ? `${node.name} ยท ${node.direction} โ€” see on the wheel` : `${node.name} โ€” see on the wheel`}
32
+ >
33
+ <span className="w-2 h-2 rounded-full mr-1.5" style={{ backgroundColor: `var(--mw-node-${node.type})` }} aria-hidden />
34
+ {node.name}
35
+ {node.direction && <span className="ml-1.5" aria-hidden>{DIRECTION_ICONS[node.direction]}</span>}
36
+ </Link>
37
+ );
38
+ }
39
+
40
+ function CeremonyMark({ honored }: { honored: boolean }) {
41
+ return honored ? (
42
+ <span className="inline-flex items-center gap-1 text-xs" style={{ color: "var(--mw-ceremony)" }} title="This relation has been honored in ceremony">
43
+ <span aria-hidden>โœฆ</span> honored
44
+ </span>
45
+ ) : (
46
+ <span className="text-xs text-muted-foreground">not yet honored</span>
47
+ );
12
48
  }
13
49
 
14
50
  export default function RelationsPage() {
15
- const [nodes, setNodes] = useState<GraphNode[]>([]);
51
+ const [nodes, setNodes] = useState<NodeRecord[]>([]);
16
52
  const [edges, setEdges] = useState<RelationalEdge[]>([]);
17
- const [selected, setSelected] = useState<string | null>(null);
18
- const [filterType, setFilterType] = useState<string>("all");
19
- const [showAddNode, setShowAddNode] = useState(false);
20
- const svgRef = useRef<SVGSVGElement>(null);
21
- const [dragging, setDragging] = useState<string | null>(null);
22
53
  const [loading, setLoading] = useState(true);
54
+ const [filterType, setFilterType] = useState<string>("all");
55
+ const [filterCeremony, setFilterCeremony] = useState<string>("all");
56
+ const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
57
+ const [showWeave, setShowWeave] = useState(false);
58
+ const [editingKey, setEditingKey] = useState<string | null>(null);
59
+ const [deletingEdge, setDeletingEdge] = useState<RelationalEdge | null>(null);
60
+ const [busy, setBusy] = useState(false);
61
+ const [weaveStrength, setWeaveStrength] = useState(0.5);
62
+ const [editStrength, setEditStrength] = useState(0.5);
23
63
 
24
64
  const loadData = useCallback(async () => {
25
65
  setLoading(true);
26
66
  try {
27
67
  const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
28
68
  const nodesResponse = await nodesRes.json();
29
- const edgesData: RelationalEdge[] = await edgesRes.json();
30
-
31
- // API returns { nodes: [...], provider: '...', count: N }
32
- const nodesData: RelationalNode[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
33
-
34
- const cx = 400, cy = 300, radius = 220;
35
- const graphNodes = nodesData.map((n, i) => {
36
- const angle = (2 * Math.PI * i) / Math.max(nodesData.length, 1);
37
- return { ...n, x: cx + radius * Math.cos(angle) + (Math.random() - 0.5) * 40, y: cy + radius * Math.sin(angle) + (Math.random() - 0.5) * 40 };
38
- });
39
- setNodes(graphNodes);
69
+ const edgesData = await edgesRes.json();
70
+ const nodesData: NodeRecord[] = Array.isArray(nodesResponse) ? nodesResponse : (nodesResponse.nodes || []);
71
+ setNodes(nodesData);
40
72
  setEdges(Array.isArray(edgesData) ? edgesData : []);
41
- } catch (err) { console.error("Failed to load relational data:", err); }
42
- finally { setLoading(false); }
73
+ } catch {
74
+ toast.error("Relations did not load โ€” check the connection and try again");
75
+ } finally {
76
+ setLoading(false);
77
+ }
43
78
  }, []);
44
79
 
45
80
  useEffect(() => { loadData(); }, [loadData]);
46
81
 
47
- const handleMouseDown = (id: string) => setDragging(id);
48
- const handleMouseUp = () => setDragging(null);
49
- const handleMouseMove = (e: MouseEvent<SVGSVGElement>) => {
50
- if (!dragging || !svgRef.current) return;
51
- const rect = svgRef.current.getBoundingClientRect();
52
- const x = ((e.clientX - rect.left) / rect.width) * 800;
53
- const y = ((e.clientY - rect.top) / rect.height) * 600;
54
- setNodes((prev) => prev.map((n) => (n.id === dragging ? { ...n, x, y } : n)));
55
- };
56
-
57
- const filteredNodes = filterType === "all" ? nodes : nodes.filter((n) => n.type === filterType);
58
- const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
59
- const filteredEdges = edges.filter((e) => filteredNodeIds.has(e.from_id) && filteredNodeIds.has(e.to_id));
60
- const selectedNode = nodes.find((n) => n.id === selected);
61
- const nodeMap = new Map(nodes.map((n) => [n.id, n]));
62
-
63
- const types: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
64
-
65
- // Simple accountability stats
66
- const totalEdges = edges.length;
67
- const ceremonied = edges.filter((e) => e.ceremony_honored).length;
68
- const unceremonied = totalEdges - ceremonied;
69
- const avgStrength = totalEdges > 0 ? edges.reduce((s, e) => s + e.strength, 0) / totalEdges : 0;
70
-
71
- async function addNode(e: FormEvent<HTMLFormElement>) {
82
+ useEffect(() => {
83
+ const params = new URLSearchParams(window.location.search);
84
+ setFocusNodeId(params.get("node"));
85
+ }, []);
86
+
87
+ const nodeMap = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
88
+ const relationTypes = useMemo(
89
+ () => Array.from(new Set(edges.map((e) => e.relationship_type))).sort(),
90
+ [edges],
91
+ );
92
+
93
+ const filteredEdges = edges.filter((e) => {
94
+ if (filterType !== "all" && e.relationship_type !== filterType) return false;
95
+ if (filterCeremony === "honored" && !e.ceremony_honored) return false;
96
+ if (filterCeremony === "unhonored" && e.ceremony_honored) return false;
97
+ if (focusNodeId && e.from_id !== focusNodeId && e.to_id !== focusNodeId) return false;
98
+ return true;
99
+ });
100
+
101
+ const honored = edges.filter((e) => e.ceremony_honored).length;
102
+ const avgStrength = edges.length > 0 ? edges.reduce((s, e) => s + e.strength, 0) / edges.length : 0;
103
+ const focusNode = focusNodeId ? nodeMap.get(focusNodeId) : undefined;
104
+
105
+ async function weaveRelation(e: SubmitEvent<HTMLFormElement>) {
106
+ e.preventDefault();
107
+ const form = new FormData(e.currentTarget);
108
+ const obligationsRaw = (form.get("obligations") as string) || "";
109
+ const body = {
110
+ from_id: form.get("from_id") as string,
111
+ to_id: form.get("to_id") as string,
112
+ relationship_type: form.get("relationship_type") as string,
113
+ strength: weaveStrength,
114
+ obligations: obligationsRaw.split(",").map((s) => s.trim()).filter(Boolean),
115
+ };
116
+ setBusy(true);
117
+ try {
118
+ const res = await fetch("/api/edges", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
119
+ const data = await res.json();
120
+ if (!res.ok) throw new Error(data.issues?.join("; ") || data.error || "Relation was not woven");
121
+ toast.success(data.replaced ? "Relation rewoven โ€” the previous thread was replaced" : "Relation woven");
122
+ setShowWeave(false);
123
+ setWeaveStrength(0.5);
124
+ loadData();
125
+ } catch (err) {
126
+ toast.error(err instanceof Error ? err.message : "Relation was not woven");
127
+ } finally {
128
+ setBusy(false);
129
+ }
130
+ }
131
+
132
+ async function saveRelation(e: SubmitEvent<HTMLFormElement>, edge: RelationalEdge) {
72
133
  e.preventDefault();
73
134
  const form = new FormData(e.currentTarget);
74
- const body = { name: form.get("name") as string, type: form.get("type") as string, direction: (form.get("direction") as string) || undefined };
75
- const res = await fetch("/api/nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
76
- if (res.ok) { toast.success("Node created"); setShowAddNode(false); loadData(); } else { toast.error("Failed"); }
135
+ const obligationsRaw = (form.get("obligations") as string) || "";
136
+ const body = {
137
+ relationship_type: form.get("relationship_type") as string,
138
+ strength: editStrength,
139
+ obligations: obligationsRaw.split(",").map((s) => s.trim()).filter(Boolean),
140
+ };
141
+ setBusy(true);
142
+ try {
143
+ const res = await fetch(`/api/edges?from=${encodeURIComponent(edge.from_id)}&to=${encodeURIComponent(edge.to_id)}`, {
144
+ method: "PATCH",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify(body),
147
+ });
148
+ const data = await res.json();
149
+ if (!res.ok) throw new Error(data.issues?.join("; ") || data.error || "Changes were not saved");
150
+ toast.success("Relation changes saved");
151
+ setEditingKey(null);
152
+ loadData();
153
+ } catch (err) {
154
+ toast.error(err instanceof Error ? err.message : "Changes were not saved");
155
+ } finally {
156
+ setBusy(false);
157
+ }
158
+ }
159
+
160
+ async function releaseRelation(edge: RelationalEdge) {
161
+ setBusy(true);
162
+ try {
163
+ const res = await fetch(`/api/edges?from=${encodeURIComponent(edge.from_id)}&to=${encodeURIComponent(edge.to_id)}`, { method: "DELETE" });
164
+ const data = await res.json();
165
+ if (!res.ok) throw new Error(data.error || "Relation was not released");
166
+ toast.success("Relation released โ€” both nodes remain in the wheel");
167
+ setDeletingEdge(null);
168
+ loadData();
169
+ } catch (err) {
170
+ toast.error(err instanceof Error ? err.message : "Relation was not released");
171
+ } finally {
172
+ setBusy(false);
173
+ }
77
174
  }
78
175
 
79
176
  return (
80
177
  <div className="p-6 max-w-7xl mx-auto">
81
- <div className="flex items-center justify-between mb-6">
178
+ <div className="flex items-center justify-between mb-6 flex-wrap gap-3">
82
179
  <div>
83
- <h1 className="text-2xl font-bold">Relational Web</h1>
84
- <p className="text-sm text-muted-foreground">{nodes.length} nodes ยท {edges.length} edges</p>
85
- </div>
86
- <div className="flex gap-2">
87
- <select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-1.5 rounded-md border bg-background text-sm">
88
- <option value="all">All Types</option>
89
- {types.map((t) => <option key={t} value={t}>{t}</option>)}
90
- </select>
91
- <button onClick={() => setShowAddNode(!showAddNode)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Add Node</button>
180
+ <h1 className="text-2xl font-bold">Relations</h1>
181
+ <p className="text-sm text-muted-foreground">
182
+ {edges.length} relation{edges.length === 1 ? "" : "s"} ยท {honored} honored in ceremony ยท average strength {Math.round(avgStrength * 100)}%
183
+ </p>
92
184
  </div>
185
+ <button
186
+ onClick={() => setShowWeave(!showWeave)}
187
+ className="mw-btn mw-btn--primary"
188
+ disabled={nodes.length < 2 && !showWeave}
189
+ title={nodes.length < 2 ? "The wheel needs at least two nodes before a relation can be woven" : undefined}
190
+ >
191
+ {showWeave ? "Close form" : "Weave relation"}
192
+ </button>
93
193
  </div>
94
194
 
95
- {showAddNode && (
96
- <form onSubmit={addNode} className="mb-6 p-4 border rounded-lg bg-card grid grid-cols-1 sm:grid-cols-4 gap-3">
97
- <input name="name" placeholder="Node name" required className="px-3 py-2 rounded-md border bg-background text-sm" />
98
- <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>
99
- <select name="direction" className="px-3 py-2 rounded-md border bg-background text-sm">
100
- <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>
101
- </select>
102
- <button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create</button>
195
+ {showWeave && (
196
+ <form onSubmit={weaveRelation} className="mb-6 p-4 border rounded-lg bg-card space-y-3">
197
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
198
+ <label className="text-sm space-y-1">
199
+ <span className="text-muted-foreground">From</span>
200
+ <select name="from_id" required className="mw-input" defaultValue={focusNodeId ?? ""}>
201
+ <option value="" disabled>Choose a node</option>
202
+ {nodes.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
203
+ </select>
204
+ </label>
205
+ <label className="text-sm space-y-1">
206
+ <span className="text-muted-foreground">Relation</span>
207
+ <input name="relationship_type" required list="relation-types" placeholder="mentorship, stewardshipโ€ฆ" className="mw-input" />
208
+ <datalist id="relation-types">
209
+ {relationTypes.map((t) => <option key={t} value={t} />)}
210
+ </datalist>
211
+ </label>
212
+ <label className="text-sm space-y-1">
213
+ <span className="text-muted-foreground">To</span>
214
+ <select name="to_id" required className="mw-input" defaultValue="">
215
+ <option value="" disabled>Choose a node</option>
216
+ {nodes.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
217
+ </select>
218
+ </label>
219
+ </div>
220
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
221
+ <label className="text-sm space-y-1">
222
+ <span className="text-muted-foreground">Strength ยท {Math.round(weaveStrength * 100)}%</span>
223
+ <input type="range" min="0" max="1" step="0.05" value={weaveStrength} onChange={(e) => setWeaveStrength(Number(e.target.value))} className="w-full" />
224
+ </label>
225
+ <label className="text-sm space-y-1">
226
+ <span className="text-muted-foreground">Obligations (comma-separated, optional)</span>
227
+ <input name="obligations" placeholder="teaching, land careโ€ฆ" className="mw-input" />
228
+ </label>
229
+ </div>
230
+ <div className="flex gap-2">
231
+ <button type="submit" disabled={busy} className="mw-btn mw-btn--primary">Weave relation</button>
232
+ <button type="button" onClick={() => setShowWeave(false)} className="mw-btn mw-btn--ghost">Cancel</button>
233
+ </div>
103
234
  </form>
104
235
  )}
105
236
 
106
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
107
- <div className="lg:col-span-2 border rounded-lg bg-card overflow-hidden relative">
108
- {(loading || nodes.length === 0) && (
109
- <div className="absolute inset-0 z-10 flex items-center justify-center text-center text-muted-foreground pointer-events-none">
110
- {loading ? "Loading relational webโ€ฆ" : "No nodes yet โ€” add one to begin mapping relations."}
111
- </div>
112
- )}
113
- <svg ref={svgRef} viewBox="0 0 800 600" className="w-full h-[600px] cursor-crosshair"
114
- onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp}>
115
- {filteredEdges.map((edge) => {
116
- const from = nodeMap.get(edge.from_id);
117
- const to = nodeMap.get(edge.to_id);
118
- if (!from || !to) return null;
119
- return (
120
- <line key={edge.id} x1={from.x} y1={from.y} x2={to.x} y2={to.y}
121
- stroke={edge.ceremony_honored ? "var(--color-primary)" : "var(--color-muted-foreground)"}
122
- strokeWidth={1 + edge.strength * 3} strokeDasharray={edge.ceremony_honored ? "none" : "6,4"} opacity={0.6} />
123
- );
124
- })}
125
- {filteredNodes.map((node) => (
126
- <g key={node.id} className="cursor-grab" onMouseDown={() => handleMouseDown(node.id)} onClick={() => setSelected(node.id)}>
127
- <circle cx={node.x} cy={node.y} r={selected === node.id ? 20 : 16}
128
- fill={NODE_TYPE_COLORS[node.type]} stroke={selected === node.id ? "var(--color-ring)" : "var(--color-border)"}
129
- strokeWidth={selected === node.id ? 3 : 1} opacity={0.9} />
130
- <text x={node.x} y={node.y + 28} textAnchor="middle" className="fill-foreground text-[10px] pointer-events-none">
131
- {node.name.length > 15 ? node.name.slice(0, 14) + "โ€ฆ" : node.name}
132
- </text>
133
- </g>
134
- ))}
135
- </svg>
136
- </div>
237
+ <div className="flex flex-wrap gap-3 mb-6 items-center">
238
+ <select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="mw-input" style={{ width: "auto" }} aria-label="Filter by relation type">
239
+ <option value="all">All relation types</option>
240
+ {relationTypes.map((t) => <option key={t} value={t}>{t}</option>)}
241
+ </select>
242
+ <select value={filterCeremony} onChange={(e) => setFilterCeremony(e.target.value)} className="mw-input" style={{ width: "auto" }} aria-label="Filter by ceremony status">
243
+ <option value="all">All ceremony states</option>
244
+ <option value="honored">Honored in ceremony</option>
245
+ <option value="unhonored">Not yet honored</option>
246
+ </select>
247
+ {focusNode && (
248
+ <span className="mw-badge">
249
+ relations of {focusNode.name}
250
+ <button onClick={() => setFocusNodeId(null)} className="ml-2 hover:text-foreground" aria-label={`Stop filtering by ${focusNode.name}`}>โœ•</button>
251
+ </span>
252
+ )}
253
+ {(filterType !== "all" || filterCeremony !== "all") && (
254
+ <button onClick={() => { setFilterType("all"); setFilterCeremony("all"); }} className="mw-btn mw-btn--ghost">Clear filters</button>
255
+ )}
256
+ </div>
137
257
 
138
- <div className="border rounded-lg bg-card p-4">
139
- {selectedNode ? (
140
- <div>
141
- <h3 className="text-lg font-semibold flex items-center gap-2">
142
- <span className="w-3 h-3 rounded-full" style={{ backgroundColor: NODE_TYPE_COLORS[selectedNode.type] }} />
143
- {selectedNode.name}
144
- </h3>
145
- <div className="mt-3 space-y-2 text-sm">
146
- <div><span className="text-muted-foreground">Type:</span> {selectedNode.type}</div>
147
- {selectedNode.direction && <div><span className="text-muted-foreground">Direction:</span> {selectedNode.direction}</div>}
148
- <div><span className="text-muted-foreground">Created:</span> <span title={absoluteTime(selectedNode.created_at)}>{relativeTime(selectedNode.created_at)}</span></div>
149
- </div>
150
- <div className="mt-4">
151
- <h4 className="text-sm font-medium mb-2">Connected Edges</h4>
152
- {edges.filter((e) => e.from_id === selectedNode.id || e.to_id === selectedNode.id).map((e) => {
153
- const other = e.from_id === selectedNode.id ? nodeMap.get(e.to_id) : nodeMap.get(e.from_id);
154
- return (
155
- <div key={e.id} className="py-1 text-sm border-b border-border/50">
156
- <span className="text-muted-foreground">{e.relationship_type}</span> โ†’ {other?.name || "?"}
157
- <span className="ml-2 text-xs text-muted-foreground">({Math.round(e.strength * 100)}%)</span>
158
- {e.ceremony_honored && <span className="ml-1 text-green-500">โœ“</span>}
159
- </div>
160
- );
161
- })}
162
- </div>
163
- </div>
258
+ {loading ? (
259
+ <div className="text-center py-12 text-muted-foreground">Loading relationsโ€ฆ</div>
260
+ ) : filteredEdges.length === 0 ? (
261
+ <div className="text-center py-12 text-muted-foreground border rounded-lg bg-card">
262
+ {edges.length === 0 ? (
263
+ nodes.length < 2 ? (
264
+ <>
265
+ <p>Relations need two nodes to hold between.</p>
266
+ <Link href="/nodes" className="mw-btn mw-btn--primary mt-4 inline-flex">Create nodes first</Link>
267
+ </>
268
+ ) : (
269
+ <>
270
+ <p>No relations yet.</p>
271
+ <button onClick={() => setShowWeave(true)} className="mw-btn mw-btn--primary mt-4">Weave the first thread</button>
272
+ </>
273
+ )
164
274
  ) : (
165
- <div className="text-center text-muted-foreground py-8">
166
- <p className="text-sm">Click a node to view details</p>
167
- <div className="mt-4 text-left">
168
- <h4 className="text-sm font-medium mb-2">Legend</h4>
169
- {types.map((t) => (
170
- <div key={t} className="flex items-center gap-2 text-sm py-0.5">
171
- <span className="w-3 h-3 rounded-full" style={{ backgroundColor: NODE_TYPE_COLORS[t] }} /> <span className="capitalize">{t}</span>
172
- </div>
173
- ))}
174
- </div>
175
- </div>
275
+ <p>No relations match these filters โ€” clear them to see the full weave.</p>
176
276
  )}
177
277
  </div>
178
- </div>
278
+ ) : (
279
+ <div className="border rounded-lg bg-card overflow-x-auto">
280
+ <table className="w-full text-sm min-w-[640px]">
281
+ <thead>
282
+ <tr className="border-b text-left text-muted-foreground">
283
+ <th className="px-4 py-3 font-medium">From</th>
284
+ <th className="px-4 py-3 font-medium">Relation</th>
285
+ <th className="px-4 py-3 font-medium">To</th>
286
+ <th className="px-4 py-3 font-medium">Ceremony</th>
287
+ <th className="px-4 py-3 font-medium">Woven</th>
288
+ <th className="px-4 py-3 font-medium text-right">Actions</th>
289
+ </tr>
290
+ </thead>
291
+ <tbody>
292
+ {filteredEdges.map((edge) => {
293
+ const key = edgeKey(edge);
294
+ const isEditing = editingKey === key;
295
+ return (
296
+ <tr key={key} className="border-b border-border/50 hover:bg-secondary/30 align-top">
297
+ {isEditing ? (
298
+ <td colSpan={6} className="px-4 py-3">
299
+ <form onSubmit={(e) => saveRelation(e, edge)} className="space-y-3">
300
+ <div className="flex items-center gap-2 flex-wrap text-sm">
301
+ <NodeChip node={nodeMap.get(edge.from_id)} id={edge.from_id} />
302
+ <span className="text-muted-foreground" aria-hidden>โ†’</span>
303
+ <NodeChip node={nodeMap.get(edge.to_id)} id={edge.to_id} />
304
+ </div>
305
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
306
+ <label className="text-sm space-y-1">
307
+ <span className="text-muted-foreground">Relation</span>
308
+ <input name="relationship_type" defaultValue={edge.relationship_type} required list="relation-types" className="mw-input" />
309
+ </label>
310
+ <label className="text-sm space-y-1">
311
+ <span className="text-muted-foreground">Strength ยท {Math.round(editStrength * 100)}%</span>
312
+ <input type="range" min="0" max="1" step="0.05" value={editStrength} onChange={(e) => setEditStrength(Number(e.target.value))} className="w-full" />
313
+ </label>
314
+ <label className="text-sm space-y-1">
315
+ <span className="text-muted-foreground">Obligations (comma-separated)</span>
316
+ <input name="obligations" defaultValue={(edge.obligations ?? []).join(", ")} className="mw-input" />
317
+ </label>
318
+ </div>
319
+ <div className="flex gap-2">
320
+ <button type="submit" disabled={busy} className="mw-btn mw-btn--primary">Save changes</button>
321
+ <button type="button" onClick={() => setEditingKey(null)} className="mw-btn mw-btn--ghost">Cancel</button>
322
+ </div>
323
+ </form>
324
+ </td>
325
+ ) : (
326
+ <>
327
+ <td className="px-4 py-3"><NodeChip node={nodeMap.get(edge.from_id)} id={edge.from_id} /></td>
328
+ <td className="px-4 py-3">
329
+ <div className="font-medium">{edge.relationship_type}</div>
330
+ <div className="flex items-center gap-2 mt-1">
331
+ <div className="mw-progress-track w-16"><div className="mw-progress-fill" style={{ width: `${edge.strength * 100}%` }} /></div>
332
+ <span className="text-xs text-muted-foreground">{Math.round(edge.strength * 100)}%</span>
333
+ </div>
334
+ {(edge.obligations?.length ?? 0) > 0 && (
335
+ <div className="text-xs text-muted-foreground mt-1">carries: {edge.obligations.join(", ")}</div>
336
+ )}
337
+ </td>
338
+ <td className="px-4 py-3"><NodeChip node={nodeMap.get(edge.to_id)} id={edge.to_id} /></td>
339
+ <td className="px-4 py-3"><CeremonyMark honored={edge.ceremony_honored} /></td>
340
+ <td className="px-4 py-3 text-muted-foreground whitespace-nowrap" title={absoluteTime(edge.created_at)}>{relativeTime(edge.created_at)}</td>
341
+ <td className="px-4 py-3 text-right whitespace-nowrap">
342
+ <button
343
+ onClick={() => { setEditingKey(key); setEditStrength(edge.strength); }}
344
+ className="mw-btn mw-btn--secondary mr-2"
345
+ >
346
+ Edit
347
+ </button>
348
+ <button onClick={() => setDeletingEdge(edge)} className="mw-btn mw-btn--danger">Release</button>
349
+ </td>
350
+ </>
351
+ )}
352
+ </tr>
353
+ );
354
+ })}
355
+ </tbody>
356
+ </table>
357
+ </div>
358
+ )}
179
359
 
180
- {/* Relational Health */}
181
- {nodes.length > 0 && (
182
- <div className="mt-6 border rounded-lg bg-card p-4">
183
- <h3 className="text-sm font-semibold text-muted-foreground uppercase mb-4">Relational Health</h3>
184
- <div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
185
- <div>
186
- <svg viewBox="0 0 48 48" className="w-12 h-12 mx-auto">
187
- <circle cx="24" cy="24" r="20" fill="none" stroke="var(--color-muted)" strokeWidth="4" />
188
- <circle cx="24" cy="24" r="20" fill="none" stroke="var(--color-primary)" strokeWidth="4"
189
- strokeDasharray={`${avgStrength * 125.6} 125.6`} strokeLinecap="round" transform="rotate(-90 24 24)" />
190
- <text x="24" y="28" textAnchor="middle" className="text-[10px] fill-foreground font-bold">{Math.round(avgStrength * 100)}%</text>
191
- </svg>
192
- <p className="text-xs text-muted-foreground mt-1">Average strength</p>
360
+ {deletingEdge && (
361
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="dialog" aria-modal="true" aria-labelledby="release-relation-title">
362
+ <div className="w-full max-w-md rounded-lg border bg-card p-6 space-y-4">
363
+ <h2 id="release-relation-title" className="text-lg font-semibold">Release this relation?</h2>
364
+ <p className="text-sm">
365
+ The thread โ€œ{deletingEdge.relationship_type}โ€ from{" "}
366
+ <strong>{nodeMap.get(deletingEdge.from_id)?.name ?? deletingEdge.from_id}</strong> to{" "}
367
+ <strong>{nodeMap.get(deletingEdge.to_id)?.name ?? deletingEdge.to_id}</strong> will be severed.
368
+ Both nodes remain in the wheel. This cannot be undone.
369
+ </p>
370
+ {deletingEdge.ceremony_honored && (
371
+ <p className="text-sm" style={{ color: "var(--mw-ceremony)" }}>
372
+ โœฆ This relation was honored in ceremony โ€” release it with intention.
373
+ </p>
374
+ )}
375
+ <div className="flex gap-2 justify-end">
376
+ <button onClick={() => setDeletingEdge(null)} className="mw-btn mw-btn--ghost">Keep the relation</button>
377
+ <button onClick={() => releaseRelation(deletingEdge)} disabled={busy} className="mw-btn mw-btn--danger border border-current">Release relation</button>
193
378
  </div>
194
- <div><p className="text-2xl font-bold">{ceremonied}</p><p className="text-xs text-muted-foreground">Ceremonied</p></div>
195
- <div><p className="text-2xl font-bold">{unceremonied}</p><p className="text-xs text-muted-foreground">Unceremonied</p></div>
196
- <div><p className="text-2xl font-bold text-green-500">{ceremonied}</p><p className="text-xs text-muted-foreground">Ceremonies honored</p></div>
197
- <div><p className="text-2xl font-bold">{unceremonied}</p><p className="text-xs text-muted-foreground">Obligations</p></div>
198
379
  </div>
199
380
  </div>
200
381
  )}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { type Direction, DIRECTION_COLORS, CEREMONY_ICONS } from "@/lib/types";
3
+ import { type Direction } from "@/lib/types";
4
4
  import { X } from "lucide-react";
5
5
  import Link from "next/link";
6
6
 
@@ -10,14 +10,20 @@ interface DirectionPanelProps {
10
10
  }
11
11
 
12
12
  export function DirectionPanel({ direction, onClose }: DirectionPanelProps) {
13
+ const dirVar = `var(--mw-${direction.name})`;
14
+ const inkVar = `var(--mw-${direction.name}-ink)`;
15
+
13
16
  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">
17
+ <div
18
+ className="w-full max-w-2xl rounded-xl border bg-card p-6 shadow-lg animate-in slide-in-from-bottom-4"
19
+ style={{ borderColor: `var(--mw-${direction.name}-border)` }}
20
+ >
15
21
  <div className="flex items-start justify-between mb-4">
16
22
  <div>
17
23
  <h2 className="text-2xl font-bold flex items-center gap-3">
18
24
  <span
19
25
  className="w-4 h-4 rounded-full inline-block"
20
- style={{ backgroundColor: DIRECTION_COLORS[direction.name] }}
26
+ style={{ backgroundColor: dirVar }}
21
27
  />
22
28
  {direction.ojibwe}
23
29
  <span className="text-muted-foreground text-lg capitalize">({direction.name})</span>
@@ -41,7 +47,7 @@ export function DirectionPanel({ direction, onClose }: DirectionPanelProps) {
41
47
  <span
42
48
  key={m}
43
49
  className="px-3 py-1 rounded-full text-sm border"
44
- style={{ borderColor: DIRECTION_COLORS[direction.name] }}
50
+ style={{ borderColor: dirVar, color: inkVar }}
45
51
  >
46
52
  ๐ŸŒฟ {m}
47
53
  </span>
@@ -56,7 +62,7 @@ export function DirectionPanel({ direction, onClose }: DirectionPanelProps) {
56
62
  <ul className="space-y-1">
57
63
  {direction.teachings.map((t) => (
58
64
  <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] }} />
65
+ <span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: dirVar }} />
60
66
  {t}
61
67
  </li>
62
68
  ))}
@@ -77,18 +83,24 @@ export function DirectionPanel({ direction, onClose }: DirectionPanelProps) {
77
83
  </div>
78
84
  </div>
79
85
 
80
- <div className="flex gap-3 mt-6 pt-4 border-t">
86
+ <div className="flex flex-wrap gap-3 mt-6 pt-4 border-t">
81
87
  <Link
82
- href={`/ceremonies?direction=${direction.name}`}
88
+ href={`/graph?direction=${direction.name}`}
83
89
  className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
84
90
  >
85
- View Ceremonies
91
+ Open in graph
92
+ </Link>
93
+ <Link
94
+ href={`/ceremonies?direction=${direction.name}`}
95
+ className="px-4 py-2 rounded-md border text-sm font-medium hover:bg-secondary"
96
+ >
97
+ View ceremonies
86
98
  </Link>
87
99
  <Link
88
100
  href={`/relations?direction=${direction.name}`}
89
101
  className="px-4 py-2 rounded-md border text-sm font-medium hover:bg-secondary"
90
102
  >
91
- View Relations
103
+ View relations
92
104
  </Link>
93
105
  </div>
94
106
  </div>