@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.
@@ -12,7 +12,9 @@ export async function GET(request: Request) {
12
12
 
13
13
  if (direction) {
14
14
  ceremonies = ceremonies.filter((c) => c.direction === direction);
15
- } else if (type) {
15
+ }
16
+
17
+ if (type) {
16
18
  ceremonies = ceremonies.filter((c) => c.type === type);
17
19
  }
18
20
 
@@ -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
- useEffect(() => {
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
- fetch(`/api/ceremonies?${params}`)
22
- .then((r) => r.json())
23
- .then((data) => setCeremonies(Array.isArray(data) ? data : []))
24
- .catch(() => setCeremonies([]));
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
- const data = await fetch("/api/ceremonies").then((r) => r.json());
43
- setCeremonies(Array.isArray(data) ? data : []);
52
+ await loadCeremonies();
44
53
  } else { toast.error("Failed to log ceremony"); }
45
54
  }
46
55
 
@@ -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.2",
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.2",
78
- "@medicine-wheel/community-review": "^0.4.2",
79
- "@medicine-wheel/consent-lifecycle": "^0.4.2",
80
- "@medicine-wheel/data-store": "^0.4.2",
81
- "@medicine-wheel/data-store-postgres": "^0.4.2",
82
- "@medicine-wheel/fire-keeper": "^0.4.2",
83
- "@medicine-wheel/graph-viz": "^0.4.2",
84
- "@medicine-wheel/importance-unit": "^0.4.2",
85
- "@medicine-wheel/mcp": "^4.4.1",
86
- "@medicine-wheel/narrative-engine": "^0.4.2",
87
- "@medicine-wheel/ontology-core": "^0.4.2",
88
- "@medicine-wheel/prompt-decomposition": "^0.4.2",
89
- "@medicine-wheel/relational-index": "^0.4.2",
90
- "@medicine-wheel/relational-query": "^0.4.2",
91
- "@medicine-wheel/session-reader": "^0.4.2",
92
- "@medicine-wheel/storage-provider": "^0.4.2",
93
- "@medicine-wheel/transformation-tracker": "^0.4.2",
94
- "@medicine-wheel/ui-components": "^0.4.2",
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",