@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.
- package/app/accountability/layout.tsx +7 -0
- package/app/accountability/page.tsx +7 -7
- package/app/api/edges/route.ts +198 -15
- package/app/api/nodes/[id]/route.ts +101 -0
- package/app/api/nodes/route.ts +39 -12
- package/app/api/plan-perspectives/[id]/route.ts +28 -0
- package/app/api/plan-perspectives/route.ts +138 -0
- package/app/ceremonies/layout.tsx +7 -0
- package/app/ceremonies/page.tsx +17 -14
- package/app/globals.css +78 -36
- package/app/graph/medicine-wheel-flow.css +157 -0
- package/app/graph/page.tsx +152 -1
- package/app/layout.tsx +32 -3
- package/app/narrative/beats/layout.tsx +7 -0
- package/app/narrative/beats/page.tsx +17 -14
- package/app/narrative/cycles/layout.tsx +7 -0
- package/app/narrative/cycles/page.tsx +13 -10
- package/app/narrative/layout.tsx +9 -0
- package/app/narrative/page.tsx +11 -8
- package/app/nodes/page.tsx +256 -82
- package/app/page.tsx +144 -61
- package/app/relations/page.tsx +338 -157
- package/components/direction-panel.tsx +21 -9
- package/components/navigation.tsx +24 -31
- package/components/workspaces-panel.tsx +10 -10
- package/package.json +21 -21
- package/public/favicon.svg +14 -0
- package/public/fonts/Stereohead.otf +0 -0
package/app/nodes/page.tsx
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState, useCallback } from "react";
|
|
4
|
-
import type {
|
|
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<
|
|
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:
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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"
|
|
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="
|
|
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
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
</
|
|
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
|
-
<
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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="
|
|
130
|
-
<button type="button" onClick={() => setShowAddNode(false)} className="
|
|
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:
|
|
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
|
|
148
|
-
<select value={filterDirection} onChange={(e) => setFilterDirection(e.target.value)} className="
|
|
149
|
-
<option value="all">All
|
|
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="
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
className=
|
|
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
|
-
<
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
<
|
|
186
|
-
<
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
220
|
-
|
|
392
|
+
</>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
221
395
|
</div>
|
|
222
396
|
)}
|
|
223
397
|
</div>
|