@medicine-wheel/app 0.4.12 β 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/accountability/layout.tsx +7 -0
- package/app/accountability/page.tsx +7 -7
- package/app/api/edges/route.ts +198 -15
- package/app/api/nodes/[id]/route.ts +101 -0
- package/app/api/nodes/route.ts +39 -12
- package/app/api/plan-perspectives/[id]/route.ts +28 -0
- package/app/api/plan-perspectives/route.ts +138 -0
- package/app/ceremonies/layout.tsx +7 -0
- package/app/ceremonies/page.tsx +17 -14
- package/app/globals.css +78 -36
- package/app/graph/medicine-wheel-flow.css +157 -0
- package/app/graph/page.tsx +152 -1
- package/app/layout.tsx +32 -3
- package/app/narrative/beats/layout.tsx +7 -0
- package/app/narrative/beats/page.tsx +17 -14
- package/app/narrative/cycles/layout.tsx +7 -0
- package/app/narrative/cycles/page.tsx +13 -10
- package/app/narrative/layout.tsx +9 -0
- package/app/narrative/page.tsx +11 -8
- package/app/nodes/page.tsx +256 -82
- package/app/page.tsx +144 -61
- package/app/relations/page.tsx +338 -157
- package/components/direction-panel.tsx +21 -9
- package/components/navigation.tsx +24 -31
- package/components/workspaces-panel.tsx +10 -10
- package/package.json +21 -21
- package/public/favicon.svg +14 -0
- package/public/fonts/Stereohead.otf +0 -0
package/app/ceremonies/page.tsx
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
import { useCallback, useEffect, useState, Suspense, useMemo } from "react";
|
|
4
4
|
import type { FormEvent } from "react";
|
|
5
5
|
import { useSearchParams } from "next/navigation";
|
|
6
|
-
import { type CeremonyLog,
|
|
6
|
+
import { type CeremonyLog, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
|
|
7
7
|
import { extractCeremonyLogs } from "@/lib/ceremony-response";
|
|
8
8
|
import { relativeTime, absoluteTime } from "@/lib/format-time";
|
|
9
9
|
import { toast } from "sonner";
|
|
10
10
|
|
|
11
|
+
const dirVar = (d: string) => `var(--mw-${d})`;
|
|
12
|
+
const dirInk = (d: string) => `var(--mw-${d}-ink)`;
|
|
13
|
+
|
|
11
14
|
function CeremoniesContent() {
|
|
12
15
|
const searchParams = useSearchParams();
|
|
13
16
|
const [ceremonies, setCeremonies] = useState<CeremonyLog[]>([]);
|
|
@@ -58,7 +61,7 @@ function CeremoniesContent() {
|
|
|
58
61
|
toast.success("Ceremony logged");
|
|
59
62
|
setShowForm(false);
|
|
60
63
|
await loadCeremonies();
|
|
61
|
-
} else { toast.error("
|
|
64
|
+
} else { toast.error("Could not save the ceremony β check the form and try again"); }
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
const directions: DirectionName[] = ["east", "south", "west", "north"];
|
|
@@ -73,10 +76,10 @@ function CeremoniesContent() {
|
|
|
73
76
|
}, [ceremonies]);
|
|
74
77
|
|
|
75
78
|
const phaseFramings: Record<string, string> = {
|
|
76
|
-
opening: "
|
|
77
|
-
council: "
|
|
78
|
-
integration: "
|
|
79
|
-
closure: "
|
|
79
|
+
opening: "We are in the Opening phase β setting intentions and acknowledging relationships",
|
|
80
|
+
council: "We are in the Council phase β deepening understanding through dialogue",
|
|
81
|
+
integration: "We are in the Integration phase β weaving learnings together",
|
|
82
|
+
closure: "We are in the Closure phase β honoring what has been shared",
|
|
80
83
|
};
|
|
81
84
|
const phases = ["opening", "council", "integration", "closure"];
|
|
82
85
|
const nextPhase = phases[(phases.indexOf(currentPhase) + 1) % 4];
|
|
@@ -85,10 +88,10 @@ function CeremoniesContent() {
|
|
|
85
88
|
<div className="p-6 max-w-5xl mx-auto">
|
|
86
89
|
<div className="flex items-center justify-between mb-6">
|
|
87
90
|
<div>
|
|
88
|
-
<h1 className="
|
|
91
|
+
<h1 className="mw-h1">Ceremonies</h1>
|
|
89
92
|
<p className="text-sm text-muted-foreground">{ceremonies.length} ceremony logs</p>
|
|
90
93
|
</div>
|
|
91
|
-
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium"
|
|
94
|
+
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">Log ceremony</button>
|
|
92
95
|
</div>
|
|
93
96
|
|
|
94
97
|
<div className="mb-6 p-4 rounded-lg border bg-card/50 backdrop-blur">
|
|
@@ -110,13 +113,13 @@ function CeremoniesContent() {
|
|
|
110
113
|
<div className="flex gap-1">
|
|
111
114
|
<button onClick={() => setFilterDir("all")} className={`px-3 py-1 rounded-full text-xs font-medium border ${filterDir === "all" ? "bg-secondary" : ""}`}>All</button>
|
|
112
115
|
{directions.map((d) => (
|
|
113
|
-
<button key={d} onClick={() => setFilterDir(d)} className={`px-3 py-1 rounded-full text-xs font-medium border capitalize ${filterDir === d ? "bg-secondary" : ""}`} style={filterDir === d ? { borderColor:
|
|
116
|
+
<button key={d} onClick={() => setFilterDir(d)} className={`px-3 py-1 rounded-full text-xs font-medium border capitalize hover:bg-secondary/60 ${filterDir === d ? "bg-secondary" : ""}`} style={filterDir === d ? { borderColor: dirVar(d) } : {}}>
|
|
114
117
|
{d}
|
|
115
118
|
</button>
|
|
116
119
|
))}
|
|
117
120
|
</div>
|
|
118
121
|
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-1 rounded-md border bg-background text-xs">
|
|
119
|
-
<option value="all">All
|
|
122
|
+
<option value="all">All types</option>
|
|
120
123
|
{cTypes.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
|
|
121
124
|
</select>
|
|
122
125
|
{filtersActive && (
|
|
@@ -136,7 +139,7 @@ function CeremoniesContent() {
|
|
|
136
139
|
<input name="medicines" placeholder="Medicines used (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm" />
|
|
137
140
|
<textarea name="intention" placeholder="Intention" rows={2} className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
|
|
138
141
|
<input name="context" placeholder="Research context (optional)" className="px-3 py-2 rounded-md border bg-background text-sm" />
|
|
139
|
-
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Log
|
|
142
|
+
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:opacity-90">Log ceremony</button>
|
|
140
143
|
</form>
|
|
141
144
|
)}
|
|
142
145
|
|
|
@@ -144,13 +147,13 @@ function CeremoniesContent() {
|
|
|
144
147
|
{loading && <div className="text-center py-12 text-muted-foreground">Loading ceremoniesβ¦</div>}
|
|
145
148
|
{!loading && ceremonies.length === 0 && (
|
|
146
149
|
<div className="text-center py-12 text-muted-foreground">
|
|
147
|
-
<p>{filtersActive ? "No ceremonies match these filters." : "No ceremonies
|
|
150
|
+
<p>{filtersActive ? "No ceremonies match these filters." : "No ceremonies yet. Log the first one to open the circle."}</p>
|
|
148
151
|
{filtersActive && <button onClick={clearFilters} className="text-sm mt-2 underline hover:text-foreground">Clear filters</button>}
|
|
149
152
|
</div>
|
|
150
153
|
)}
|
|
151
154
|
{!loading && ceremonies.map((c) => (
|
|
152
155
|
<div key={c.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50 transition-colors"
|
|
153
|
-
style={{ borderTopColor:
|
|
156
|
+
style={{ borderTopColor: dirVar(c.direction), borderTopWidth: 3 }} onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}>
|
|
154
157
|
<div className="p-4 flex items-center justify-between gap-3">
|
|
155
158
|
<div className="flex items-center gap-3 min-w-0">
|
|
156
159
|
<span className="text-2xl shrink-0">{CEREMONY_ICONS[c.type]}</span>
|
|
@@ -158,7 +161,7 @@ function CeremoniesContent() {
|
|
|
158
161
|
<div className="flex items-center gap-2">
|
|
159
162
|
<span className="font-medium text-sm capitalize">{c.type.replace("_", " ")}</span>
|
|
160
163
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium capitalize border"
|
|
161
|
-
style={{ borderColor:
|
|
164
|
+
style={{ borderColor: `var(--mw-${c.direction}-border)`, color: dirInk(c.direction) }}>
|
|
162
165
|
{DIRECTION_ICONS[c.direction]} {c.direction}
|
|
163
166
|
</span>
|
|
164
167
|
</div>
|
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 />
|