@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/globals.css
CHANGED
|
@@ -1,44 +1,48 @@
|
|
|
1
|
-
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
|
2
1
|
@import "tailwindcss";
|
|
3
2
|
@import "../src/ui-components/src/tokens.css";
|
|
4
3
|
|
|
4
|
+
/*
|
|
5
|
+
* Tailwind theme mirrors tokens.css (the source of truth).
|
|
6
|
+
* Fonts arrive via next/font in app/layout.tsx as
|
|
7
|
+
* --font-fraunces / --font-inter / --font-jbmono.
|
|
8
|
+
*/
|
|
5
9
|
@theme inline {
|
|
6
|
-
--color-background:
|
|
7
|
-
--color-foreground:
|
|
8
|
-
--color-card:
|
|
9
|
-
--color-card-foreground:
|
|
10
|
-
--color-popover:
|
|
11
|
-
--color-popover-foreground:
|
|
12
|
-
--color-primary:
|
|
13
|
-
--color-primary-foreground:
|
|
14
|
-
--color-secondary:
|
|
15
|
-
--color-secondary-foreground:
|
|
16
|
-
--color-muted:
|
|
17
|
-
--color-muted-foreground:
|
|
18
|
-
--color-accent:
|
|
19
|
-
--color-accent-foreground:
|
|
20
|
-
--color-destructive:
|
|
21
|
-
--color-destructive-foreground:
|
|
22
|
-
--color-border:
|
|
23
|
-
--color-input:
|
|
24
|
-
--color-ring:
|
|
10
|
+
--color-background: var(--mw-bg);
|
|
11
|
+
--color-foreground: var(--mw-fg);
|
|
12
|
+
--color-card: var(--mw-card);
|
|
13
|
+
--color-card-foreground: var(--mw-fg);
|
|
14
|
+
--color-popover: var(--mw-card);
|
|
15
|
+
--color-popover-foreground: var(--mw-fg);
|
|
16
|
+
--color-primary: var(--mw-primary);
|
|
17
|
+
--color-primary-foreground: var(--mw-primary-fg);
|
|
18
|
+
--color-secondary: var(--mw-secondary);
|
|
19
|
+
--color-secondary-foreground: var(--mw-fg);
|
|
20
|
+
--color-muted: var(--mw-secondary);
|
|
21
|
+
--color-muted-foreground: var(--mw-muted);
|
|
22
|
+
--color-accent: var(--mw-secondary);
|
|
23
|
+
--color-accent-foreground: var(--mw-fg);
|
|
24
|
+
--color-destructive: var(--mw-destructive);
|
|
25
|
+
--color-destructive-foreground: var(--mw-fg);
|
|
26
|
+
--color-border: var(--mw-border);
|
|
27
|
+
--color-input: var(--mw-border);
|
|
28
|
+
--color-ring: var(--mw-ring);
|
|
25
29
|
--radius: 0.625rem;
|
|
26
30
|
|
|
27
|
-
--color-east:
|
|
28
|
-
--color-south:
|
|
29
|
-
--color-west:
|
|
30
|
-
--color-north:
|
|
31
|
+
--color-east: var(--mw-east);
|
|
32
|
+
--color-south: var(--mw-south);
|
|
33
|
+
--color-west: var(--mw-west);
|
|
34
|
+
--color-north: var(--mw-north);
|
|
31
35
|
|
|
32
|
-
--color-node-human:
|
|
33
|
-
--color-node-land:
|
|
34
|
-
--color-node-spirit:
|
|
35
|
-
--color-node-ancestor:
|
|
36
|
-
--color-node-future:
|
|
37
|
-
--color-node-knowledge:
|
|
36
|
+
--color-node-human: var(--mw-node-human);
|
|
37
|
+
--color-node-land: var(--mw-node-land);
|
|
38
|
+
--color-node-spirit: var(--mw-node-spirit);
|
|
39
|
+
--color-node-ancestor: var(--mw-node-ancestor);
|
|
40
|
+
--color-node-future: var(--mw-node-future);
|
|
41
|
+
--color-node-knowledge: var(--mw-node-knowledge);
|
|
38
42
|
|
|
39
|
-
--font-sans: '
|
|
40
|
-
--font-display: '
|
|
41
|
-
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
|
43
|
+
--font-sans: var(--font-inter, 'Inter'), system-ui, -apple-system, sans-serif;
|
|
44
|
+
--font-display: var(--font-fraunces, 'Fraunces'), 'Iowan Old Style', Georgia, serif;
|
|
45
|
+
--font-mono: var(--font-jbmono, 'JetBrains Mono'), ui-monospace, monospace;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
@layer base {
|
|
@@ -49,10 +53,44 @@
|
|
|
49
53
|
@apply bg-background text-foreground;
|
|
50
54
|
font-family: var(--font-sans);
|
|
51
55
|
}
|
|
52
|
-
h1
|
|
56
|
+
/* Display face is reserved for page titles + home hero (h1 only) */
|
|
57
|
+
h1 {
|
|
53
58
|
font-family: var(--font-display);
|
|
54
|
-
font-weight:
|
|
55
|
-
letter-spacing: 0.01em;
|
|
59
|
+
font-weight: var(--weight-medium);
|
|
60
|
+
letter-spacing: -0.01em;
|
|
61
|
+
}
|
|
62
|
+
h2 {
|
|
63
|
+
font-family: var(--font-sans);
|
|
64
|
+
font-weight: var(--weight-semibold);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Navigation — item color arrives via --nav-c (a token var, set inline) */
|
|
69
|
+
@layer components {
|
|
70
|
+
.nav-item {
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
padding: 0.5rem 0.75rem;
|
|
73
|
+
border-radius: var(--radius-sm);
|
|
74
|
+
font-size: var(--text-sm);
|
|
75
|
+
font-weight: var(--weight-medium);
|
|
76
|
+
color: var(--color-muted-foreground);
|
|
77
|
+
transition: background var(--duration-fast) ease, color var(--duration-fast) ease;
|
|
78
|
+
}
|
|
79
|
+
.nav-item:hover {
|
|
80
|
+
color: var(--color-foreground);
|
|
81
|
+
background: color-mix(in srgb, var(--nav-c, var(--mw-muted)) 12%, transparent);
|
|
82
|
+
}
|
|
83
|
+
.nav-item--active {
|
|
84
|
+
color: var(--color-foreground);
|
|
85
|
+
background: var(--color-secondary);
|
|
86
|
+
box-shadow: inset 0 -2px 0 var(--nav-c, var(--mw-ring));
|
|
87
|
+
}
|
|
88
|
+
.nav-scroll {
|
|
89
|
+
overflow-x: auto;
|
|
90
|
+
scrollbar-width: none;
|
|
91
|
+
}
|
|
92
|
+
.nav-scroll::-webkit-scrollbar {
|
|
93
|
+
display: none;
|
|
56
94
|
}
|
|
57
95
|
}
|
|
58
96
|
|
|
@@ -66,3 +104,7 @@
|
|
|
66
104
|
}
|
|
67
105
|
.animate-pulse-center { animation: pulse-center 3s ease-in-out infinite; }
|
|
68
106
|
.animate-glow { animation: glow 2s ease-in-out infinite; }
|
|
107
|
+
|
|
108
|
+
@media (prefers-reduced-motion: reduce) {
|
|
109
|
+
.animate-pulse-center, .animate-glow { animation: none; }
|
|
110
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Medicine Wheel flow theme — React Flow v12 internals tuned to the
|
|
3
|
+
* midnight-indigo ground. Imported by app/graph/page.tsx AFTER
|
|
4
|
+
* `@xyflow/react/dist/style.css`.
|
|
5
|
+
*
|
|
6
|
+
* Palette rules (design-direction 2607):
|
|
7
|
+
* - Direction color = information. Nothing here claims a direction.
|
|
8
|
+
* - Ceremony gold #FFD700 is reserved for ceremony-honored elements,
|
|
9
|
+
* so selection/interaction chrome uses neutral bone/slate tones.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
.react-flow.dark {
|
|
13
|
+
--xy-background-color: var(--mw-bg, #0a0a1a);
|
|
14
|
+
--xy-background-pattern-dots-color: rgba(255, 255, 255, 0.1);
|
|
15
|
+
|
|
16
|
+
--xy-edge-stroke: #55516a;
|
|
17
|
+
--xy-edge-stroke-selected: var(--mw-north, #f7f5f0);
|
|
18
|
+
|
|
19
|
+
--xy-connectionline-stroke: var(--mw-north, #f7f5f0);
|
|
20
|
+
|
|
21
|
+
--xy-controls-button-background-color: rgba(255, 255, 255, 0.06);
|
|
22
|
+
--xy-controls-button-background-color-hover: rgba(255, 255, 255, 0.14);
|
|
23
|
+
--xy-controls-button-color: #cbd5e1;
|
|
24
|
+
--xy-controls-button-color-hover: #ffffff;
|
|
25
|
+
--xy-controls-button-border-color: rgba(255, 255, 255, 0.1);
|
|
26
|
+
--xy-controls-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
27
|
+
|
|
28
|
+
--xy-minimap-background-color: #101020;
|
|
29
|
+
|
|
30
|
+
--xy-selection-background-color: rgba(247, 245, 240, 0.05);
|
|
31
|
+
--xy-selection-border: 1px dashed rgba(247, 245, 240, 0.45);
|
|
32
|
+
|
|
33
|
+
--xy-attribution-background-color: transparent;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Controls: quiet panel that reads as part of the instrument */
|
|
37
|
+
.react-flow.dark .react-flow__controls {
|
|
38
|
+
border-radius: 10px;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
backdrop-filter: blur(4px);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.react-flow.dark .react-flow__controls-button {
|
|
44
|
+
width: 28px;
|
|
45
|
+
height: 28px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.react-flow.dark .react-flow__controls-button:focus-visible {
|
|
49
|
+
outline: 2px solid var(--mw-ring, #cbd5e1);
|
|
50
|
+
outline-offset: -2px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* MiniMap: rounded, bordered, round node dots */
|
|
54
|
+
.react-flow.dark .react-flow__minimap {
|
|
55
|
+
border-radius: 12px;
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ── Weaving relations ─────────────────────────────────────────────── */
|
|
61
|
+
|
|
62
|
+
/* Cardinal source handles: hidden until the node is hovered/selected */
|
|
63
|
+
.react-flow__node .mw-node-handle {
|
|
64
|
+
width: 9px;
|
|
65
|
+
height: 9px;
|
|
66
|
+
border-radius: 9999px;
|
|
67
|
+
background: var(--mw-north, #f7f5f0);
|
|
68
|
+
border: 1.5px solid var(--mw-bg, #0a0a1a);
|
|
69
|
+
opacity: 0;
|
|
70
|
+
/* React Flow positions handles via transform — never animate it here. */
|
|
71
|
+
transition: opacity 150ms ease;
|
|
72
|
+
cursor: crosshair;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.react-flow__node:hover .mw-node-handle,
|
|
76
|
+
.react-flow__node.selected .mw-node-handle,
|
|
77
|
+
.react-flow__node:focus-visible .mw-node-handle {
|
|
78
|
+
opacity: 0.9;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.react-flow__node .mw-node-handle:hover {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* The in-flight thread: dashed, neutral — it only turns gold if the
|
|
86
|
+
relation is later ceremony-honored. */
|
|
87
|
+
.react-flow.dark .react-flow__connectionline path {
|
|
88
|
+
stroke-dasharray: 8 4;
|
|
89
|
+
stroke-width: 1.75;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ── Context menus ─────────────────────────────────────────────────── */
|
|
93
|
+
|
|
94
|
+
.mw-context-menu-item:not(:disabled):hover {
|
|
95
|
+
background: rgba(255, 255, 255, 0.08);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ── Node hover & focus states ─────────────────────────────────────── */
|
|
99
|
+
|
|
100
|
+
.react-flow__node {
|
|
101
|
+
cursor: grab;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.react-flow__node.dragging {
|
|
105
|
+
cursor: grabbing;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.react-flow__node > div {
|
|
109
|
+
transition: transform 150ms ease;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.react-flow__node:hover > div {
|
|
113
|
+
transform: scale(1.06);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ── Hover neighbor-dimming: the circle holds its relations in light ── */
|
|
117
|
+
|
|
118
|
+
.mw-hovering .react-flow__node:not(.mw-neighbor) {
|
|
119
|
+
opacity: 0.22;
|
|
120
|
+
transition: opacity 200ms ease;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.react-flow__node {
|
|
124
|
+
transition: opacity 200ms ease;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── Selection halo breath ─────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
@keyframes mw-halo-breathe {
|
|
130
|
+
0%,
|
|
131
|
+
100% {
|
|
132
|
+
opacity: 0.6;
|
|
133
|
+
}
|
|
134
|
+
50% {
|
|
135
|
+
opacity: 0.95;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.mw-selection-halo {
|
|
140
|
+
animation: mw-halo-breathe 2.4s var(--ease-in-out, ease-in-out) infinite;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@media (prefers-reduced-motion: reduce) {
|
|
144
|
+
.mw-selection-halo {
|
|
145
|
+
animation: none;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.react-flow__node > div,
|
|
149
|
+
.react-flow__node,
|
|
150
|
+
.mw-hovering .react-flow__node:not(.mw-neighbor) {
|
|
151
|
+
transition: none;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.react-flow__node:hover > div {
|
|
155
|
+
transform: none;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/app/graph/page.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import "@xyflow/react/dist/style.css";
|
|
4
|
+
import "./medicine-wheel-flow.css";
|
|
4
5
|
|
|
5
6
|
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
6
7
|
import type { ChangeEvent } from "react";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
applyWheelLayout,
|
|
23
24
|
buildGraphData,
|
|
24
25
|
type MWGraphData,
|
|
26
|
+
type MWGraphLink,
|
|
25
27
|
type MWGraphNode,
|
|
26
28
|
type MWGraphNodePositions,
|
|
27
29
|
} from "@medicine-wheel/graph-viz";
|
|
@@ -85,10 +87,16 @@ function graphNodePositions(data: MWGraphData): MWGraphNodePositions {
|
|
|
85
87
|
return positions;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
const DIRECTION_NAMES = ["east", "south", "west", "north"] as const;
|
|
91
|
+
type DirectionParam = (typeof DIRECTION_NAMES)[number];
|
|
92
|
+
|
|
88
93
|
export default function GraphPage() {
|
|
89
94
|
const router = useRouter();
|
|
90
95
|
const [graph, setGraph] = useState<MWGraphData>({ nodes: [], links: [] });
|
|
91
96
|
const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
|
|
97
|
+
const [focusedNodeId, setFocusedNodeId] = useState<string | undefined>(undefined);
|
|
98
|
+
const [highlightDirection, setHighlightDirection] = useState<DirectionParam | undefined>(undefined);
|
|
99
|
+
const [radialSnap, setRadialSnap] = useState<"off" | "ring" | "sector">("off");
|
|
92
100
|
const [loading, setLoading] = useState(true);
|
|
93
101
|
const [showLabels, setShowLabels] = useState(true);
|
|
94
102
|
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
@@ -153,6 +161,30 @@ export default function GraphPage() {
|
|
|
153
161
|
setAnimationsEnabled(loadStoredGraphAnimationPreference());
|
|
154
162
|
}, []);
|
|
155
163
|
|
|
164
|
+
// Deep links: ?node=<id> focuses a being; ?direction=<dir> keeps that
|
|
165
|
+
// quadrant's beings lit (arriving from the home wheel).
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const params = new URLSearchParams(window.location.search);
|
|
168
|
+
const node = params.get("node");
|
|
169
|
+
if (node) setFocusedNodeId(node);
|
|
170
|
+
const direction = params.get("direction");
|
|
171
|
+
if (direction && (DIRECTION_NAMES as readonly string[]).includes(direction)) {
|
|
172
|
+
setHighlightDirection(direction as DirectionParam);
|
|
173
|
+
}
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
// Surface the deep-linked node in the side panel once data arrives.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!focusedNodeId || graph.nodes.length === 0) return;
|
|
179
|
+
const node = graph.nodes.find((n) => n.id === focusedNodeId);
|
|
180
|
+
if (node) setSelectedNode(node);
|
|
181
|
+
}, [focusedNodeId, graph.nodes]);
|
|
182
|
+
|
|
183
|
+
const graphData = useMemo<MWGraphData>(
|
|
184
|
+
() => (focusedNodeId ? { ...graph, focusedNodeId } : graph),
|
|
185
|
+
[graph, focusedNodeId],
|
|
186
|
+
);
|
|
187
|
+
|
|
156
188
|
useEffect(() => {
|
|
157
189
|
layoutStoreRef.current = layoutStore;
|
|
158
190
|
}, [layoutStore]);
|
|
@@ -233,6 +265,31 @@ export default function GraphPage() {
|
|
|
233
265
|
[router],
|
|
234
266
|
);
|
|
235
267
|
|
|
268
|
+
const handleRelationCreate = useCallback(
|
|
269
|
+
async (sourceId: string, targetId: string) => {
|
|
270
|
+
try {
|
|
271
|
+
const res = await fetch("/api/edges", {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
from_id: sourceId,
|
|
276
|
+
to_id: targetId,
|
|
277
|
+
relationship_type: "speaks-with",
|
|
278
|
+
strength: 0.5,
|
|
279
|
+
}),
|
|
280
|
+
});
|
|
281
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
282
|
+
toast.success("Relation woven — speaks-with");
|
|
283
|
+
} catch {
|
|
284
|
+
toast.error("Could not save the relation — restoring the canvas");
|
|
285
|
+
} finally {
|
|
286
|
+
// Reload canonical data: confirms the new thread or reverts it.
|
|
287
|
+
await loadData();
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
[loadData],
|
|
291
|
+
);
|
|
292
|
+
|
|
236
293
|
const handleAnimationsEnabledChange = useCallback(
|
|
237
294
|
(event: ChangeEvent<HTMLInputElement>) => {
|
|
238
295
|
const enabled = event.target.checked;
|
|
@@ -245,6 +302,83 @@ export default function GraphPage() {
|
|
|
245
302
|
[],
|
|
246
303
|
);
|
|
247
304
|
|
|
305
|
+
const handleNodeCreateRequest = useCallback(
|
|
306
|
+
async (request: {
|
|
307
|
+
name: string;
|
|
308
|
+
type: string;
|
|
309
|
+
direction?: string;
|
|
310
|
+
position: { x: number; y: number };
|
|
311
|
+
}) => {
|
|
312
|
+
try {
|
|
313
|
+
const res = await fetch("/api/nodes", {
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: { "Content-Type": "application/json" },
|
|
316
|
+
body: JSON.stringify({
|
|
317
|
+
name: request.name,
|
|
318
|
+
type: request.type,
|
|
319
|
+
direction: request.direction,
|
|
320
|
+
}),
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
323
|
+
const { node } = await res.json();
|
|
324
|
+
// Seat the new being exactly where the circle was opened.
|
|
325
|
+
if (node?.id) {
|
|
326
|
+
saveLayoutStore(
|
|
327
|
+
upsertActiveGraphLayout(layoutStoreRef.current, {
|
|
328
|
+
[node.id]: request.position,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
toast.success(`${request.name} placed in the ${request.direction ?? "center"}`);
|
|
333
|
+
} catch {
|
|
334
|
+
toast.error("Could not create the node");
|
|
335
|
+
} finally {
|
|
336
|
+
await loadData();
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
[loadData, saveLayoutStore],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const handleEdgeCeremonyRequest = useCallback(
|
|
343
|
+
async (link: MWGraphLink) => {
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(
|
|
346
|
+
`/api/edges?from=${encodeURIComponent(link.source)}&to=${encodeURIComponent(link.target)}`,
|
|
347
|
+
{
|
|
348
|
+
method: "PATCH",
|
|
349
|
+
headers: { "Content-Type": "application/json" },
|
|
350
|
+
body: JSON.stringify({ ceremony_honored: true }),
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
354
|
+
toast.success("Ceremony honored — the thread turns gold");
|
|
355
|
+
} catch {
|
|
356
|
+
toast.error("Could not honor the ceremony");
|
|
357
|
+
} finally {
|
|
358
|
+
await loadData();
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
[loadData],
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const handleEdgeDeleteRequest = useCallback(
|
|
365
|
+
async (link: MWGraphLink) => {
|
|
366
|
+
try {
|
|
367
|
+
const res = await fetch(
|
|
368
|
+
`/api/edges?from=${encodeURIComponent(link.source)}&to=${encodeURIComponent(link.target)}`,
|
|
369
|
+
{ method: "DELETE" },
|
|
370
|
+
);
|
|
371
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
372
|
+
toast.success("Relation released");
|
|
373
|
+
} catch {
|
|
374
|
+
toast.error("Could not release the relation");
|
|
375
|
+
} finally {
|
|
376
|
+
await loadData();
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
[loadData],
|
|
380
|
+
);
|
|
381
|
+
|
|
248
382
|
return (
|
|
249
383
|
<div className="min-h-screen bg-[#0a0a1a] text-white p-6">
|
|
250
384
|
<div className="max-w-7xl mx-auto">
|
|
@@ -264,6 +398,15 @@ export default function GraphPage() {
|
|
|
264
398
|
>
|
|
265
399
|
Labels {showLabels ? "ON" : "OFF"}
|
|
266
400
|
</button>
|
|
401
|
+
<button
|
|
402
|
+
onClick={() =>
|
|
403
|
+
setRadialSnap((s) => (s === "off" ? "ring" : s === "ring" ? "sector" : "off"))
|
|
404
|
+
}
|
|
405
|
+
className={`px-3 py-1.5 rounded text-sm ${radialSnap !== "off" ? "bg-white/10" : "bg-white/5"}`}
|
|
406
|
+
title="Radial snapping: the wheel guides dragged beings — ring keeps them in their band, sector also inside their quadrant"
|
|
407
|
+
>
|
|
408
|
+
Snap {radialSnap === "off" ? "OFF" : radialSnap.toUpperCase()}
|
|
409
|
+
</button>
|
|
267
410
|
<button onClick={loadData} className="px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10 inline-flex items-center gap-2">
|
|
268
411
|
<RefreshCw className="h-4 w-4" /> Refresh
|
|
269
412
|
</button>
|
|
@@ -294,7 +437,7 @@ export default function GraphPage() {
|
|
|
294
437
|
</div>
|
|
295
438
|
) : (
|
|
296
439
|
<MedicineWheelFlowGraph
|
|
297
|
-
data={
|
|
440
|
+
data={graphData}
|
|
298
441
|
height={600}
|
|
299
442
|
darkMode
|
|
300
443
|
showNodeLabels={showLabels}
|
|
@@ -303,6 +446,14 @@ export default function GraphPage() {
|
|
|
303
446
|
onNodeClick={(node) => setSelectedNode(node)}
|
|
304
447
|
onNodeDoubleClick={navigateToNode}
|
|
305
448
|
onNodePositionsChange={handleNodePositionsChange}
|
|
449
|
+
enableConnections
|
|
450
|
+
onRelationCreate={handleRelationCreate}
|
|
451
|
+
onNodeOpen={navigateToNode}
|
|
452
|
+
onNodeCreateRequest={handleNodeCreateRequest}
|
|
453
|
+
onEdgeCeremonyRequest={handleEdgeCeremonyRequest}
|
|
454
|
+
onEdgeDeleteRequest={handleEdgeDeleteRequest}
|
|
455
|
+
highlightDirection={highlightDirection}
|
|
456
|
+
radialSnap={radialSnap}
|
|
306
457
|
/>
|
|
307
458
|
)}
|
|
308
459
|
</div>
|
package/app/layout.tsx
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
+
import { Fraunces, Inter, JetBrains_Mono } from "next/font/google";
|
|
2
3
|
import "./globals.css";
|
|
3
4
|
import { ThemeProvider } from "@/components/theme-provider";
|
|
4
5
|
import { Toaster } from "sonner";
|
|
5
6
|
import { Navigation } from "@/components/navigation";
|
|
6
7
|
|
|
8
|
+
// Display face — page titles + home hero only (see design tokens).
|
|
9
|
+
const fraunces = Fraunces({
|
|
10
|
+
subsets: ["latin"],
|
|
11
|
+
variable: "--font-fraunces",
|
|
12
|
+
axes: ["opsz"],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const inter = Inter({
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
variable: "--font-inter",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const jetbrainsMono = JetBrains_Mono({
|
|
21
|
+
subsets: ["latin"],
|
|
22
|
+
variable: "--font-jbmono",
|
|
23
|
+
});
|
|
24
|
+
|
|
7
25
|
export const metadata: Metadata = {
|
|
8
|
-
title:
|
|
9
|
-
|
|
26
|
+
title: {
|
|
27
|
+
default: "Medicine Wheel — Relational Research",
|
|
28
|
+
template: "%s · Medicine Wheel",
|
|
29
|
+
},
|
|
30
|
+
description:
|
|
31
|
+
"Interactive visual layer for the Medicine Wheel — ceremonies, Four Directions, relational web, and narrative arcs",
|
|
32
|
+
icons: {
|
|
33
|
+
icon: "/favicon.svg",
|
|
34
|
+
},
|
|
10
35
|
};
|
|
11
36
|
|
|
12
37
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
13
38
|
return (
|
|
14
|
-
<html
|
|
39
|
+
<html
|
|
40
|
+
lang="en"
|
|
41
|
+
suppressHydrationWarning
|
|
42
|
+
className={`${fraunces.variable} ${inter.variable} ${jetbrainsMono.variable}`}
|
|
43
|
+
>
|
|
15
44
|
<body className="font-sans antialiased">
|
|
16
45
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} forcedTheme="dark">
|
|
17
46
|
<Navigation />
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import type { FormEvent } from "react";
|
|
5
|
-
import { type NarrativeBeat,
|
|
5
|
+
import { type NarrativeBeat, type DirectionName } from "@/lib/types";
|
|
6
6
|
import { toast } from "sonner";
|
|
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 BeatsPage() {
|
|
9
12
|
const [beats, setBeats] = useState<NarrativeBeat[]>([]);
|
|
10
13
|
const [viewMode, setViewMode] = useState<"timeline" | "direction">("timeline");
|
|
@@ -34,11 +37,11 @@ export default function BeatsPage() {
|
|
|
34
37
|
};
|
|
35
38
|
const res = await fetch("/api/narrative/beats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
36
39
|
if (res.ok) {
|
|
37
|
-
toast.success("Beat
|
|
40
|
+
toast.success("Beat added");
|
|
38
41
|
setShowForm(false);
|
|
39
42
|
const data = await fetch("/api/narrative/beats").then((r) => r.json());
|
|
40
43
|
setBeats(Array.isArray(data) ? data : []);
|
|
41
|
-
} else { toast.error("
|
|
44
|
+
} else { toast.error("Could not save the beat — check the form and try again"); }
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
// Direction summary
|
|
@@ -52,13 +55,13 @@ export default function BeatsPage() {
|
|
|
52
55
|
<div className="p-6 max-w-6xl mx-auto">
|
|
53
56
|
<div className="flex items-center justify-between mb-6">
|
|
54
57
|
<div>
|
|
55
|
-
<h1 className="
|
|
58
|
+
<h1 className="mw-h1">Narrative beats</h1>
|
|
56
59
|
<p className="text-sm text-muted-foreground">{beats.length} beats across the Four Directions</p>
|
|
57
60
|
</div>
|
|
58
61
|
<div className="flex gap-2">
|
|
59
62
|
<button onClick={() => setViewMode(viewMode === "timeline" ? "direction" : "timeline")}
|
|
60
|
-
className="px-3 py-1.5 rounded-md border text-sm">{viewMode === "timeline" ? "By
|
|
61
|
-
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium"
|
|
63
|
+
className="px-3 py-1.5 rounded-md border text-sm hover:bg-secondary/60">{viewMode === "timeline" ? "By direction" : "Timeline"}</button>
|
|
64
|
+
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">Add beat</button>
|
|
62
65
|
</div>
|
|
63
66
|
</div>
|
|
64
67
|
|
|
@@ -66,9 +69,9 @@ export default function BeatsPage() {
|
|
|
66
69
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
|
67
70
|
{directions.map((dir) => (
|
|
68
71
|
<button key={dir} onClick={() => setFilterDir(filterDir === dir ? "all" : dir)}
|
|
69
|
-
className={`p-3 rounded-lg border text-center transition-all ${filterDir === dir ? "ring-2 ring-primary" : ""}`}
|
|
70
|
-
style={{ borderColor:
|
|
71
|
-
<span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor:
|
|
72
|
+
className={`p-3 rounded-lg border text-center transition-all hover:bg-secondary/40 ${filterDir === dir ? "ring-2 ring-primary" : ""}`}
|
|
73
|
+
style={{ borderColor: `var(--mw-${dir}-border)` }}>
|
|
74
|
+
<span className="w-3 h-3 rounded-full inline-block mb-1" style={{ backgroundColor: dirVar(dir) }} />
|
|
72
75
|
<p className="text-sm capitalize font-medium">{dir}</p>
|
|
73
76
|
<p className="text-lg font-bold">{dirCounts[dir]}</p>
|
|
74
77
|
<p className="text-xs text-muted-foreground">{dirToPhase[dir]} {visitedDirs.has(dir) ? "✓" : ""}</p>
|
|
@@ -88,19 +91,19 @@ export default function BeatsPage() {
|
|
|
88
91
|
<option value="1">Act 1 (East)</option><option value="2">Act 2 (South)</option><option value="3">Act 3 (West)</option><option value="4">Act 4 (North)</option>
|
|
89
92
|
</select>
|
|
90
93
|
<input name="learnings" placeholder="Learnings (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
|
|
91
|
-
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">
|
|
94
|
+
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:opacity-90">Add beat</button>
|
|
92
95
|
</form>
|
|
93
96
|
)}
|
|
94
97
|
|
|
95
98
|
{viewMode === "timeline" ? (
|
|
96
99
|
<div className="relative pl-8">
|
|
97
100
|
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
|
|
98
|
-
{filteredBeats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats yet.</div>}
|
|
101
|
+
{filteredBeats.length === 0 && <div className="text-center py-8 text-muted-foreground">No beats yet. Add the first one to begin the arc.</div>}
|
|
99
102
|
{filteredBeats.map((beat) => (
|
|
100
103
|
<div key={beat.id} className="relative mb-4" onClick={() => setExpandedBeat(expandedBeat === beat.id ? null : beat.id)}>
|
|
101
|
-
<div className="absolute -left-5 top-4 w-3 h-3 rounded-full border-2 border-background" style={{ backgroundColor:
|
|
104
|
+
<div className="absolute -left-5 top-4 w-3 h-3 rounded-full border-2 border-background" style={{ backgroundColor: dirVar(beat.direction) }} />
|
|
102
105
|
<div className="border rounded-lg bg-card p-4 cursor-pointer hover:border-ring/50"
|
|
103
|
-
style={{ borderLeftColor:
|
|
106
|
+
style={{ borderLeftColor: dirVar(beat.direction), borderLeftWidth: 3 }}>
|
|
104
107
|
<div className="flex items-center justify-between">
|
|
105
108
|
<div>
|
|
106
109
|
<h3 className="font-medium">{beat.title}</h3>
|
|
@@ -125,7 +128,7 @@ export default function BeatsPage() {
|
|
|
125
128
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
126
129
|
{directions.map((dir) => (
|
|
127
130
|
<div key={dir} className="space-y-2">
|
|
128
|
-
<h3 className="font-semibold capitalize text-center py-2 rounded-md" style={{ backgroundColor:
|
|
131
|
+
<h3 className="font-semibold capitalize text-center py-2 rounded-md" style={{ backgroundColor: `var(--mw-${dir}-tint)`, color: dirInk(dir) }}>
|
|
129
132
|
{dir} ({dirCounts[dir]})
|
|
130
133
|
</h3>
|
|
131
134
|
{beats.filter((b) => b.direction === dir).map((beat) => (
|