@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.
- 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/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/next.config.mjs +18 -0
- package/package.json +25 -21
- package/public/favicon.svg +14 -0
- package/public/fonts/Stereohead.otf +0 -0
package/app/relations/page.tsx
CHANGED
|
@@ -1,200 +1,381 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, useCallback
|
|
4
|
-
import type {
|
|
5
|
-
import
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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<
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
75
|
-
const
|
|
76
|
-
|
|
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">
|
|
84
|
-
<p className="text-sm text-muted-foreground">
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
{
|
|
96
|
-
<form onSubmit={
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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="
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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={`/
|
|
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
|
-
|
|
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
|
|
103
|
+
View relations
|
|
92
104
|
</Link>
|
|
93
105
|
</div>
|
|
94
106
|
</div>
|