@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,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>