@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
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import type { FormEvent } from "react";
|
|
5
|
-
import { type MedicineWheelCycle,
|
|
5
|
+
import { type MedicineWheelCycle, type DirectionName } from "@/lib/types";
|
|
6
6
|
import { extractCycles } from "@/lib/cycle-response";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
8
|
|
|
9
|
+
const dirVar = (d: string) => `var(--mw-${d})`;
|
|
10
|
+
const dirInk = (d: string) => `var(--mw-${d}-ink)`;
|
|
11
|
+
|
|
9
12
|
export default function CyclesPage() {
|
|
10
13
|
const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
|
|
11
14
|
const [showForm, setShowForm] = useState(false);
|
|
@@ -28,7 +31,7 @@ export default function CyclesPage() {
|
|
|
28
31
|
setShowForm(false);
|
|
29
32
|
const data = await fetch("/api/narrative/cycles").then((r) => r.json());
|
|
30
33
|
setCycles(extractCycles(data));
|
|
31
|
-
} else { toast.error("
|
|
34
|
+
} else { toast.error("Could not create the cycle — try again"); }
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
const dirOrder: DirectionName[] = ["east", "south", "west", "north"];
|
|
@@ -37,18 +40,18 @@ export default function CyclesPage() {
|
|
|
37
40
|
<div className="p-6 max-w-5xl mx-auto">
|
|
38
41
|
<div className="flex items-center justify-between mb-6">
|
|
39
42
|
<div>
|
|
40
|
-
<h1 className="
|
|
43
|
+
<h1 className="mw-h1">Cycles</h1>
|
|
41
44
|
<p className="text-sm text-muted-foreground">{cycles.length} research cycles</p>
|
|
42
45
|
</div>
|
|
43
|
-
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium"
|
|
46
|
+
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">New cycle</button>
|
|
44
47
|
</div>
|
|
45
48
|
|
|
46
49
|
{showForm && (
|
|
47
50
|
<form onSubmit={createCycle} className="mb-6 p-4 border rounded-lg bg-card">
|
|
48
51
|
<textarea name="question" placeholder="What is your research question?" rows={3} required className="w-full px-3 py-2 rounded-md border bg-background text-sm mb-3" />
|
|
49
52
|
<div className="flex gap-2">
|
|
50
|
-
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Create
|
|
51
|
-
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-md border text-sm">Cancel</button>
|
|
53
|
+
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:opacity-90">Create cycle</button>
|
|
54
|
+
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 rounded-md border text-sm hover:bg-secondary/60">Cancel</button>
|
|
52
55
|
</div>
|
|
53
56
|
</form>
|
|
54
57
|
)}
|
|
@@ -78,7 +81,7 @@ export default function CyclesPage() {
|
|
|
78
81
|
return (
|
|
79
82
|
<path key={dir}
|
|
80
83
|
d={`M 24 24 L ${x1} ${y1} A 20 20 0 0 1 ${x2} ${y2} Z`}
|
|
81
|
-
fill={
|
|
84
|
+
fill={dirVar(dir)}
|
|
82
85
|
opacity={completed ? 0.9 : 0.2}
|
|
83
86
|
/>
|
|
84
87
|
);
|
|
@@ -89,7 +92,7 @@ export default function CyclesPage() {
|
|
|
89
92
|
<div className="flex-1 min-w-0">
|
|
90
93
|
<h3 className="font-semibold mb-1">{cycle.research_question}</h3>
|
|
91
94
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
92
|
-
<span className="capitalize" style={{ color:
|
|
95
|
+
<span className="capitalize" style={{ color: dirInk(cycle.current_direction) }}>
|
|
93
96
|
● {cycle.current_direction}
|
|
94
97
|
</span>
|
|
95
98
|
<span>{cycle.beats.length} beats</span>
|
|
@@ -101,7 +104,7 @@ export default function CyclesPage() {
|
|
|
101
104
|
<div className="mt-3 h-2 bg-secondary rounded-full overflow-hidden">
|
|
102
105
|
<div className="h-full rounded-full transition-all" style={{
|
|
103
106
|
width: `${progress}%`,
|
|
104
|
-
background:
|
|
107
|
+
background: "linear-gradient(to right, var(--mw-east), var(--mw-south), var(--mw-west), var(--mw-north))",
|
|
105
108
|
}} />
|
|
106
109
|
</div>
|
|
107
110
|
</div>
|
|
@@ -114,7 +117,7 @@ export default function CyclesPage() {
|
|
|
114
117
|
{dirOrder.map((dir) => {
|
|
115
118
|
const active = dirOrder.indexOf(dir) <= dirIdx;
|
|
116
119
|
return (
|
|
117
|
-
<div key={dir} className="text-center p-2 rounded-md border" style={{ borderColor: active ?
|
|
120
|
+
<div key={dir} className="text-center p-2 rounded-md border" style={{ borderColor: active ? dirVar(dir) : "var(--color-border)", opacity: active ? 1 : 0.4 }}>
|
|
118
121
|
<p className="text-xs capitalize font-medium">{dir}</p>
|
|
119
122
|
<p className="text-sm">{active ? "✓" : "○"}</p>
|
|
120
123
|
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: { default: "Narrative", template: "%s · Medicine Wheel" },
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export default function NarrativeLayout({ children }: { children: React.ReactNode }) {
|
|
8
|
+
return children;
|
|
9
|
+
}
|
package/app/narrative/page.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import { type NarrativeBeat, type MedicineWheelCycle,
|
|
4
|
+
import { type NarrativeBeat, type MedicineWheelCycle, type DirectionName } from "@/lib/types";
|
|
5
5
|
import { extractCycles } from "@/lib/cycle-response";
|
|
6
6
|
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
7
7
|
|
|
8
|
+
const dirVar = (d: string) => `var(--mw-${d})`;
|
|
9
|
+
const dirInk = (d: string) => `var(--mw-${d}-ink)`;
|
|
10
|
+
|
|
8
11
|
export default function NarrativePage() {
|
|
9
12
|
const [beats, setBeats] = useState<NarrativeBeat[]>([]);
|
|
10
13
|
const [cycles, setCycles] = useState<MedicineWheelCycle[]>([]);
|
|
@@ -25,7 +28,7 @@ export default function NarrativePage() {
|
|
|
25
28
|
|
|
26
29
|
return (
|
|
27
30
|
<div className="p-6 max-w-5xl mx-auto">
|
|
28
|
-
<h1 className="
|
|
31
|
+
<h1 className="mw-h1 mb-2">Narrative arc</h1>
|
|
29
32
|
<p className="text-sm text-muted-foreground mb-6">Journey through the Four Directions</p>
|
|
30
33
|
|
|
31
34
|
{activeCycle && (
|
|
@@ -40,7 +43,7 @@ export default function NarrativePage() {
|
|
|
40
43
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
|
41
44
|
<div>
|
|
42
45
|
<p className="text-sm text-muted-foreground">Current Direction</p>
|
|
43
|
-
<p className="text-lg font-bold capitalize" style={{ color:
|
|
46
|
+
<p className="text-lg font-bold capitalize" style={{ color: dirInk(activeCycle.current_direction) }}>
|
|
44
47
|
{activeCycle.current_direction}
|
|
45
48
|
</p>
|
|
46
49
|
</div>
|
|
@@ -59,7 +62,7 @@ export default function NarrativePage() {
|
|
|
59
62
|
</div>
|
|
60
63
|
<div className="mt-4">
|
|
61
64
|
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
|
62
|
-
<div className="h-full rounded-full transition-all" style={{ width: `${progress}%`, background:
|
|
65
|
+
<div className="h-full rounded-full transition-all" style={{ width: `${progress}%`, background: "linear-gradient(to right, var(--mw-east), var(--mw-south), var(--mw-west), var(--mw-north))" }} />
|
|
63
66
|
</div>
|
|
64
67
|
<div className="flex justify-between mt-1 text-xs text-muted-foreground">
|
|
65
68
|
{dirOrder.map((d) => <span key={d} className="capitalize">{d}</span>)}
|
|
@@ -73,8 +76,8 @@ export default function NarrativePage() {
|
|
|
73
76
|
{dirOrder.map((dir) => {
|
|
74
77
|
const count = beats.filter((b) => b.direction === dir).length;
|
|
75
78
|
return (
|
|
76
|
-
<div key={dir} className="p-3 rounded-lg border text-center" style={{ borderColor:
|
|
77
|
-
<span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor:
|
|
79
|
+
<div key={dir} className="p-3 rounded-lg border text-center" style={{ borderColor: `var(--mw-${dir}-border)` }}>
|
|
80
|
+
<span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor: dirVar(dir) }} />
|
|
78
81
|
<p className="text-sm capitalize font-medium">{dir}</p>
|
|
79
82
|
<p className="text-lg font-bold">{count}</p>
|
|
80
83
|
<p className="text-xs text-muted-foreground">beats</p>
|
|
@@ -87,10 +90,10 @@ export default function NarrativePage() {
|
|
|
87
90
|
<h2 className="text-lg font-semibold mb-3">Narrative Beats ({beats.length})</h2>
|
|
88
91
|
<div className="space-y-3">
|
|
89
92
|
{loading && <div className="text-center py-8 text-muted-foreground">Loading narrative beats…</div>}
|
|
90
|
-
{!loading && beats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats
|
|
93
|
+
{!loading && beats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats yet. Record the first one on the Beats page to begin the arc.</div>}
|
|
91
94
|
{!loading && beats.map((beat) => (
|
|
92
95
|
<div key={beat.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50"
|
|
93
|
-
style={{ borderLeftColor:
|
|
96
|
+
style={{ borderLeftColor: dirVar(beat.direction), borderLeftWidth: 4 }}
|
|
94
97
|
onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
|
|
95
98
|
<div className="p-4">
|
|
96
99
|
<div className="flex items-center justify-between">
|
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>
|