@medicine-wheel/app 0.4.2 → 0.4.4
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/api/ceremonies/route.ts +3 -1
- package/app/ceremonies/page.tsx +17 -8
- package/app/graph/page.tsx +39 -1
- package/lib/ceremony-response.ts +16 -0
- package/lib/graph-animation-storage.ts +84 -0
- package/package.json +19 -19
package/app/ceremonies/page.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, Suspense, useMemo } from "react";
|
|
3
|
+
import { useCallback, useEffect, useState, Suspense, useMemo } from "react";
|
|
4
4
|
import type { FormEvent } from "react";
|
|
5
5
|
import { useSearchParams } from "next/navigation";
|
|
6
6
|
import { type CeremonyLog, DIRECTION_COLORS, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
|
|
7
|
+
import { extractCeremonyLogs } from "@/lib/ceremony-response";
|
|
7
8
|
import { toast } from "sonner";
|
|
8
9
|
|
|
9
10
|
function CeremoniesContent() {
|
|
@@ -14,16 +15,25 @@ function CeremoniesContent() {
|
|
|
14
15
|
const [showForm, setShowForm] = useState(false);
|
|
15
16
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const loadCeremonies = useCallback(async () => {
|
|
18
19
|
const params = new URLSearchParams();
|
|
19
20
|
if (filterDir !== "all") params.set("direction", filterDir);
|
|
20
21
|
if (filterType !== "all") params.set("type", filterType);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`/api/ceremonies?${params}`);
|
|
25
|
+
if (!res.ok) throw new Error("Failed to load ceremonies");
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
setCeremonies(extractCeremonyLogs(data));
|
|
28
|
+
} catch {
|
|
29
|
+
setCeremonies([]);
|
|
30
|
+
}
|
|
25
31
|
}, [filterDir, filterType]);
|
|
26
32
|
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
loadCeremonies();
|
|
35
|
+
}, [loadCeremonies]);
|
|
36
|
+
|
|
27
37
|
async function logCeremony(e: FormEvent<HTMLFormElement>) {
|
|
28
38
|
e.preventDefault();
|
|
29
39
|
const form = new FormData(e.currentTarget);
|
|
@@ -39,8 +49,7 @@ function CeremoniesContent() {
|
|
|
39
49
|
if (res.ok) {
|
|
40
50
|
toast.success("Ceremony logged");
|
|
41
51
|
setShowForm(false);
|
|
42
|
-
|
|
43
|
-
setCeremonies(Array.isArray(data) ? data : []);
|
|
52
|
+
await loadCeremonies();
|
|
44
53
|
} else { toast.error("Failed to log ceremony"); }
|
|
45
54
|
}
|
|
46
55
|
|
package/app/graph/page.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import "@xyflow/react/dist/style.css";
|
|
4
4
|
|
|
5
5
|
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
6
|
+
import type { ChangeEvent } from "react";
|
|
6
7
|
import dynamic from "next/dynamic";
|
|
7
8
|
import Link from "next/link";
|
|
8
9
|
import { useRouter } from "next/navigation";
|
|
@@ -34,6 +35,11 @@ import {
|
|
|
34
35
|
upsertCurrentGraphLayout,
|
|
35
36
|
type GraphLayoutStore,
|
|
36
37
|
} from "@/lib/graph-layout-storage";
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_GRAPH_ANIMATION_ENABLED,
|
|
40
|
+
loadStoredGraphAnimationPreference,
|
|
41
|
+
persistGraphAnimationPreference,
|
|
42
|
+
} from "@/lib/graph-animation-storage";
|
|
37
43
|
import { toast } from "sonner";
|
|
38
44
|
|
|
39
45
|
// React Flow touches `window`/`document`, so the interactive renderer is
|
|
@@ -84,6 +90,9 @@ export default function GraphPage() {
|
|
|
84
90
|
const [selectedNode, setSelectedNode] = useState<MWGraphNode | null>(null);
|
|
85
91
|
const [loading, setLoading] = useState(true);
|
|
86
92
|
const [showLabels, setShowLabels] = useState(true);
|
|
93
|
+
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
94
|
+
DEFAULT_GRAPH_ANIMATION_ENABLED,
|
|
95
|
+
);
|
|
87
96
|
const [layoutName, setLayoutName] = useState("");
|
|
88
97
|
const [layoutsHydrated, setLayoutsHydrated] = useState(false);
|
|
89
98
|
const [layoutStore, setLayoutStore] = useState<GraphLayoutStore>(() =>
|
|
@@ -139,6 +148,10 @@ export default function GraphPage() {
|
|
|
139
148
|
setLayoutsHydrated(true);
|
|
140
149
|
}, []);
|
|
141
150
|
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
setAnimationsEnabled(loadStoredGraphAnimationPreference());
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
142
155
|
useEffect(() => {
|
|
143
156
|
layoutStoreRef.current = layoutStore;
|
|
144
157
|
}, [layoutStore]);
|
|
@@ -215,6 +228,18 @@ export default function GraphPage() {
|
|
|
215
228
|
[router],
|
|
216
229
|
);
|
|
217
230
|
|
|
231
|
+
const handleAnimationsEnabledChange = useCallback(
|
|
232
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
233
|
+
const enabled = event.target.checked;
|
|
234
|
+
setAnimationsEnabled(enabled);
|
|
235
|
+
|
|
236
|
+
if (!persistGraphAnimationPreference(enabled)) {
|
|
237
|
+
toast.error("Could not save graph animation preference");
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
[],
|
|
241
|
+
);
|
|
242
|
+
|
|
218
243
|
return (
|
|
219
244
|
<div className="min-h-screen bg-[#0a0a1a] text-white p-6">
|
|
220
245
|
<div className="max-w-7xl mx-auto">
|
|
@@ -241,7 +266,19 @@ export default function GraphPage() {
|
|
|
241
266
|
</div>
|
|
242
267
|
|
|
243
268
|
<div className="flex gap-6">
|
|
244
|
-
<div className="flex-1 rounded-xl border border-white/10 overflow-hidden">
|
|
269
|
+
<div className="relative flex-1 rounded-xl border border-white/10 overflow-hidden">
|
|
270
|
+
{!loading && graph.nodes.length > 0 && (
|
|
271
|
+
<label className="absolute right-3 top-3 z-10 inline-flex items-center gap-2 rounded-md border border-white/10 bg-[#0a0a1a]/80 px-2 py-1 text-[11px] text-gray-300 shadow-sm backdrop-blur">
|
|
272
|
+
<input
|
|
273
|
+
type="checkbox"
|
|
274
|
+
checked={animationsEnabled}
|
|
275
|
+
onChange={handleAnimationsEnabledChange}
|
|
276
|
+
className="h-3 w-3 accent-yellow-400"
|
|
277
|
+
aria-label="Animate graph edges"
|
|
278
|
+
/>
|
|
279
|
+
<span>Animation</span>
|
|
280
|
+
</label>
|
|
281
|
+
)}
|
|
245
282
|
{loading ? (
|
|
246
283
|
<div className="flex items-center justify-center h-[600px] text-gray-500">Loading graph data...</div>
|
|
247
284
|
) : graph.nodes.length === 0 ? (
|
|
@@ -256,6 +293,7 @@ export default function GraphPage() {
|
|
|
256
293
|
height={600}
|
|
257
294
|
darkMode
|
|
258
295
|
showNodeLabels={showLabels}
|
|
296
|
+
animationsEnabled={animationsEnabled}
|
|
259
297
|
nodePositions={activeLayout.positions}
|
|
260
298
|
onNodeClick={(node) => setSelectedNode(node)}
|
|
261
299
|
onNodeDoubleClick={navigateToNode}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CeremonyLog } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
interface CeremonyListResponse {
|
|
4
|
+
ceremonies?: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
|
|
8
|
+
if (Array.isArray(response)) return response as CeremonyLog[];
|
|
9
|
+
|
|
10
|
+
if (!response || typeof response !== "object") return [];
|
|
11
|
+
|
|
12
|
+
const candidate = response as CeremonyListResponse;
|
|
13
|
+
return Array.isArray(candidate.ceremonies)
|
|
14
|
+
? (candidate.ceremonies as CeremonyLog[])
|
|
15
|
+
: [];
|
|
16
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const GRAPH_ANIMATION_STORAGE_VERSION = 1;
|
|
2
|
+
export const GRAPH_ANIMATION_STORAGE_KEY = "medicine-wheel:graph-animation:v1";
|
|
3
|
+
export const DEFAULT_GRAPH_ANIMATION_ENABLED = true;
|
|
4
|
+
|
|
5
|
+
interface StorageLike {
|
|
6
|
+
getItem(key: string): string | null;
|
|
7
|
+
setItem(key: string, value: string): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface GraphAnimationPreference {
|
|
11
|
+
version: typeof GRAPH_ANIMATION_STORAGE_VERSION;
|
|
12
|
+
animationsEnabled: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeStorage(): StorageLike | null {
|
|
16
|
+
if (typeof window === "undefined") return null;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
return window.localStorage;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function serializeGraphAnimationPreference(
|
|
26
|
+
animationsEnabled: boolean,
|
|
27
|
+
): string {
|
|
28
|
+
const value: GraphAnimationPreference = {
|
|
29
|
+
version: GRAPH_ANIMATION_STORAGE_VERSION,
|
|
30
|
+
animationsEnabled,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseGraphAnimationPreference(raw: string | null): boolean {
|
|
37
|
+
if (!raw) return DEFAULT_GRAPH_ANIMATION_ENABLED;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw) as Partial<GraphAnimationPreference>;
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
parsed.version === GRAPH_ANIMATION_STORAGE_VERSION &&
|
|
44
|
+
typeof parsed.animationsEnabled === "boolean"
|
|
45
|
+
) {
|
|
46
|
+
return parsed.animationsEnabled;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return DEFAULT_GRAPH_ANIMATION_ENABLED;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return DEFAULT_GRAPH_ANIMATION_ENABLED;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadStoredGraphAnimationPreference(
|
|
56
|
+
storage: StorageLike | null = safeStorage(),
|
|
57
|
+
): boolean {
|
|
58
|
+
if (!storage) return DEFAULT_GRAPH_ANIMATION_ENABLED;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return parseGraphAnimationPreference(
|
|
62
|
+
storage.getItem(GRAPH_ANIMATION_STORAGE_KEY),
|
|
63
|
+
);
|
|
64
|
+
} catch {
|
|
65
|
+
return DEFAULT_GRAPH_ANIMATION_ENABLED;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function persistGraphAnimationPreference(
|
|
70
|
+
animationsEnabled: boolean,
|
|
71
|
+
storage: StorageLike | null = safeStorage(),
|
|
72
|
+
): boolean {
|
|
73
|
+
if (!storage) return false;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
storage.setItem(
|
|
77
|
+
GRAPH_ANIMATION_STORAGE_KEY,
|
|
78
|
+
serializeGraphAnimationPreference(animationsEnabled),
|
|
79
|
+
);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mw": "dist/cli/mw.js",
|
|
@@ -74,24 +74,24 @@
|
|
|
74
74
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@medicine-wheel/ceremony-protocol": "^0.4.
|
|
78
|
-
"@medicine-wheel/community-review": "^0.4.
|
|
79
|
-
"@medicine-wheel/consent-lifecycle": "^0.4.
|
|
80
|
-
"@medicine-wheel/data-store": "^0.4.
|
|
81
|
-
"@medicine-wheel/data-store-postgres": "^0.4.
|
|
82
|
-
"@medicine-wheel/fire-keeper": "^0.4.
|
|
83
|
-
"@medicine-wheel/graph-viz": "^0.4.
|
|
84
|
-
"@medicine-wheel/importance-unit": "^0.4.
|
|
85
|
-
"@medicine-wheel/mcp": "^4.4.
|
|
86
|
-
"@medicine-wheel/narrative-engine": "^0.4.
|
|
87
|
-
"@medicine-wheel/ontology-core": "^0.4.
|
|
88
|
-
"@medicine-wheel/prompt-decomposition": "^0.4.
|
|
89
|
-
"@medicine-wheel/relational-index": "^0.4.
|
|
90
|
-
"@medicine-wheel/relational-query": "^0.4.
|
|
91
|
-
"@medicine-wheel/session-reader": "^0.4.
|
|
92
|
-
"@medicine-wheel/storage-provider": "^0.4.
|
|
93
|
-
"@medicine-wheel/transformation-tracker": "^0.4.
|
|
94
|
-
"@medicine-wheel/ui-components": "^0.4.
|
|
77
|
+
"@medicine-wheel/ceremony-protocol": "^0.4.4",
|
|
78
|
+
"@medicine-wheel/community-review": "^0.4.4",
|
|
79
|
+
"@medicine-wheel/consent-lifecycle": "^0.4.4",
|
|
80
|
+
"@medicine-wheel/data-store": "^0.4.4",
|
|
81
|
+
"@medicine-wheel/data-store-postgres": "^0.4.4",
|
|
82
|
+
"@medicine-wheel/fire-keeper": "^0.4.4",
|
|
83
|
+
"@medicine-wheel/graph-viz": "^0.4.4",
|
|
84
|
+
"@medicine-wheel/importance-unit": "^0.4.4",
|
|
85
|
+
"@medicine-wheel/mcp": "^4.4.2",
|
|
86
|
+
"@medicine-wheel/narrative-engine": "^0.4.4",
|
|
87
|
+
"@medicine-wheel/ontology-core": "^0.4.4",
|
|
88
|
+
"@medicine-wheel/prompt-decomposition": "^0.4.4",
|
|
89
|
+
"@medicine-wheel/relational-index": "^0.4.4",
|
|
90
|
+
"@medicine-wheel/relational-query": "^0.4.4",
|
|
91
|
+
"@medicine-wheel/session-reader": "^0.4.4",
|
|
92
|
+
"@medicine-wheel/storage-provider": "^0.4.4",
|
|
93
|
+
"@medicine-wheel/transformation-tracker": "^0.4.4",
|
|
94
|
+
"@medicine-wheel/ui-components": "^0.4.4",
|
|
95
95
|
"@neondatabase/serverless": "^0.10.0",
|
|
96
96
|
"clsx": "^2.1.1",
|
|
97
97
|
"lucide-react": "^0.475.0",
|