@medicine-wheel/app 0.2.3
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/Dockerfile +69 -0
- package/LICENSE +205 -0
- package/README.md +201 -0
- package/app/accountability/page.tsx +95 -0
- package/app/api/ceremonies/route.ts +52 -0
- package/app/api/directions/route.ts +6 -0
- package/app/api/edges/route.ts +27 -0
- package/app/api/health/route.ts +37 -0
- package/app/api/narrative/beats/route.ts +29 -0
- package/app/api/narrative/cycles/route.ts +23 -0
- package/app/api/nodes/route.ts +52 -0
- package/app/api/resources/route.ts +48 -0
- package/app/ceremonies/page.tsx +161 -0
- package/app/globals.css +68 -0
- package/app/graph/page.tsx +200 -0
- package/app/layout.tsx +24 -0
- package/app/narrative/beats/page.tsx +145 -0
- package/app/narrative/cycles/page.tsx +143 -0
- package/app/narrative/page.tsx +113 -0
- package/app/nodes/page.tsx +199 -0
- package/app/page.tsx +148 -0
- package/app/relations/page.tsx +191 -0
- package/components/direction-panel.tsx +96 -0
- package/components/navigation.tsx +105 -0
- package/components/theme-provider.tsx +11 -0
- package/components/workspaces-panel.tsx +110 -0
- package/dist/cli/mw.js +731 -0
- package/dist/cli/mwsrv.js +267 -0
- package/docker-build-push.sh +15 -0
- package/docker-entrypoint.sh +26 -0
- package/lib/jsonl-store.ts +586 -0
- package/lib/store.ts +226 -0
- package/lib/types.ts +23 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +5 -0
- package/package.json +112 -0
- package/postcss.config.mjs +6 -0
- package/public/fonts/Stereohead.otf +0 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { createProvider, detectProvider } from '@medicine-wheel/storage-provider';
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const providerType = detectProvider();
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const store = await createProvider();
|
|
9
|
+
|
|
10
|
+
// Test basic connectivity
|
|
11
|
+
const nodes = await store.getAllNodes();
|
|
12
|
+
const ceremonies = await store.getAllCeremonies();
|
|
13
|
+
|
|
14
|
+
return NextResponse.json({
|
|
15
|
+
status: 'healthy',
|
|
16
|
+
provider: providerType,
|
|
17
|
+
counts: {
|
|
18
|
+
nodes: nodes.length,
|
|
19
|
+
ceremonies: ceremonies.length,
|
|
20
|
+
},
|
|
21
|
+
env: {
|
|
22
|
+
MW_STORAGE_PROVIDER: process.env.MW_STORAGE_PROVIDER || 'not set',
|
|
23
|
+
DATABASE_URL: process.env.DATABASE_URL ? 'configured' : 'not configured',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return NextResponse.json({
|
|
28
|
+
status: 'unhealthy',
|
|
29
|
+
provider: providerType,
|
|
30
|
+
error: String(error),
|
|
31
|
+
env: {
|
|
32
|
+
MW_STORAGE_PROVIDER: process.env.MW_STORAGE_PROVIDER || 'not set',
|
|
33
|
+
DATABASE_URL: process.env.DATABASE_URL ? 'configured' : 'not configured',
|
|
34
|
+
},
|
|
35
|
+
}, { status: 500 });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getAllBeats, createBeat } from "@/lib/store";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
try {
|
|
6
|
+
return NextResponse.json(getAllBeats());
|
|
7
|
+
} catch (error: any) {
|
|
8
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function POST(request: Request) {
|
|
13
|
+
try {
|
|
14
|
+
const body = await request.json();
|
|
15
|
+
const beat = createBeat({
|
|
16
|
+
direction: body.direction,
|
|
17
|
+
title: body.title,
|
|
18
|
+
description: body.description,
|
|
19
|
+
prose: body.prose,
|
|
20
|
+
ceremonies: body.ceremonies ?? [],
|
|
21
|
+
learnings: body.learnings ?? [],
|
|
22
|
+
act: body.act ?? 1,
|
|
23
|
+
relations_honored: body.relations_honored ?? [],
|
|
24
|
+
});
|
|
25
|
+
return NextResponse.json(beat, { status: 201 });
|
|
26
|
+
} catch (error: any) {
|
|
27
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getAllCycles, createCycle } from "@/lib/store";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
try {
|
|
6
|
+
return NextResponse.json(getAllCycles());
|
|
7
|
+
} catch (error: any) {
|
|
8
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function POST(request: Request) {
|
|
13
|
+
try {
|
|
14
|
+
const body = await request.json();
|
|
15
|
+
const cycle = createCycle({
|
|
16
|
+
research_question: body.research_question,
|
|
17
|
+
current_direction: body.current_direction,
|
|
18
|
+
});
|
|
19
|
+
return NextResponse.json(cycle, { status: 201 });
|
|
20
|
+
} catch (error: any) {
|
|
21
|
+
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
|
+
|
|
4
|
+
export async function GET(request: Request) {
|
|
5
|
+
try {
|
|
6
|
+
const { searchParams } = new URL(request.url);
|
|
7
|
+
const type = searchParams.get("type");
|
|
8
|
+
const direction = searchParams.get("direction");
|
|
9
|
+
|
|
10
|
+
const store = await createProvider();
|
|
11
|
+
let nodes = await store.getAllNodes();
|
|
12
|
+
|
|
13
|
+
if (type) {
|
|
14
|
+
nodes = nodes.filter((n) => n.type === type);
|
|
15
|
+
} else if (direction) {
|
|
16
|
+
nodes = nodes.filter((n) => n.direction === direction);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return NextResponse.json({
|
|
20
|
+
nodes,
|
|
21
|
+
provider: detectProvider(),
|
|
22
|
+
count: nodes.length
|
|
23
|
+
});
|
|
24
|
+
} catch (error: unknown) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function POST(request: Request) {
|
|
31
|
+
try {
|
|
32
|
+
const store = await createProvider();
|
|
33
|
+
const body = await request.json();
|
|
34
|
+
|
|
35
|
+
const node = {
|
|
36
|
+
id: body.id || crypto.randomUUID(),
|
|
37
|
+
name: body.name,
|
|
38
|
+
type: body.type,
|
|
39
|
+
description: body.description || "",
|
|
40
|
+
direction: body.direction || undefined,
|
|
41
|
+
metadata: body.metadata || {},
|
|
42
|
+
created_at: new Date().toISOString(),
|
|
43
|
+
updated_at: new Date().toISOString(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await store.createNode(node);
|
|
47
|
+
return NextResponse.json({ success: true, node, provider: detectProvider() }, { status: 201 });
|
|
48
|
+
} catch (error: unknown) {
|
|
49
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
50
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const RESOURCES = [
|
|
4
|
+
{
|
|
5
|
+
id: "wilson-paradigm",
|
|
6
|
+
name: "Shawn Wilson's Indigenous Research Paradigm",
|
|
7
|
+
description: "Research is Ceremony — ontology, epistemology, axiology framework",
|
|
8
|
+
content: {
|
|
9
|
+
ontology: "Reality is relational — all things exist in relationship",
|
|
10
|
+
epistemology: "Knowledge is shared and ceremony-based",
|
|
11
|
+
axiology: "Relational accountability to all relations",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "two-eyed-seeing",
|
|
16
|
+
name: "Two-Eyed Seeing (Etuaptmumk)",
|
|
17
|
+
description: "Mi'kmaw framework for integrating Indigenous and Western knowledge",
|
|
18
|
+
content: {
|
|
19
|
+
principle: "Learn to see from one eye with the strengths of Indigenous knowledge, and from the other eye with the strengths of Western knowledge, and to use both these eyes together",
|
|
20
|
+
source: "Elder Albert Marshall, Mi'kmaq",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "ocap",
|
|
25
|
+
name: "OCAP® Data Sovereignty",
|
|
26
|
+
description: "Ownership, Control, Access, Possession principles for First Nations data",
|
|
27
|
+
content: {
|
|
28
|
+
ownership: "Community collectively owns cultural information",
|
|
29
|
+
control: "Community controls research processes and data management",
|
|
30
|
+
access: "Community determines who has access",
|
|
31
|
+
possession: "Data must be physically possessed by community (on-premise)",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "methodologies",
|
|
36
|
+
name: "Indigenous Research Methodologies",
|
|
37
|
+
description: "Kapati, Storywork, and Conversational methods",
|
|
38
|
+
content: {
|
|
39
|
+
kapati: "Kaupapa Māori research approach",
|
|
40
|
+
storywork: "Jo-ann Archibald's Indigenous storywork methodology",
|
|
41
|
+
conversational: "Relational dialogue-based research",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export async function GET() {
|
|
47
|
+
return NextResponse.json(RESOURCES);
|
|
48
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, Suspense, useMemo } from "react";
|
|
4
|
+
import type { FormEvent } from "react";
|
|
5
|
+
import { useSearchParams } from "next/navigation";
|
|
6
|
+
import { type CeremonyLog, DIRECTION_COLORS, CEREMONY_ICONS, type DirectionName, type CeremonyType } from "@/lib/types";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
|
|
9
|
+
function CeremoniesContent() {
|
|
10
|
+
const searchParams = useSearchParams();
|
|
11
|
+
const [ceremonies, setCeremonies] = useState<CeremonyLog[]>([]);
|
|
12
|
+
const [filterDir, setFilterDir] = useState<string>(searchParams.get("direction") || "all");
|
|
13
|
+
const [filterType, setFilterType] = useState<string>("all");
|
|
14
|
+
const [showForm, setShowForm] = useState(false);
|
|
15
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const params = new URLSearchParams();
|
|
19
|
+
if (filterDir !== "all") params.set("direction", filterDir);
|
|
20
|
+
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([]));
|
|
25
|
+
}, [filterDir, filterType]);
|
|
26
|
+
|
|
27
|
+
async function logCeremony(e: FormEvent<HTMLFormElement>) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
const form = new FormData(e.currentTarget);
|
|
30
|
+
const body = {
|
|
31
|
+
type: form.get("type") as string,
|
|
32
|
+
direction: form.get("direction") as string,
|
|
33
|
+
participants: (form.get("participants") as string).split(",").map((s) => s.trim()).filter(Boolean),
|
|
34
|
+
medicines_used: (form.get("medicines") as string).split(",").map((s) => s.trim()).filter(Boolean),
|
|
35
|
+
intentions: [(form.get("intention") as string)].filter(Boolean),
|
|
36
|
+
research_context: (form.get("context") as string) || undefined,
|
|
37
|
+
};
|
|
38
|
+
const res = await fetch("/api/ceremonies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
toast.success("Ceremony logged");
|
|
41
|
+
setShowForm(false);
|
|
42
|
+
const data = await fetch("/api/ceremonies").then((r) => r.json());
|
|
43
|
+
setCeremonies(Array.isArray(data) ? data : []);
|
|
44
|
+
} else { toast.error("Failed to log ceremony"); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const directions: DirectionName[] = ["east", "south", "west", "north"];
|
|
48
|
+
const cTypes: CeremonyType[] = ["smudging", "talking_circle", "spirit_feeding", "opening", "closing"];
|
|
49
|
+
|
|
50
|
+
const phaseMap: Record<DirectionName, string> = { east: "opening", south: "council", west: "integration", north: "closure" };
|
|
51
|
+
const currentPhase = useMemo(() => {
|
|
52
|
+
if (ceremonies.length === 0) return "opening";
|
|
53
|
+
const latest = [...ceremonies].sort((a, b) => b.timestamp.localeCompare(a.timestamp))[0];
|
|
54
|
+
return phaseMap[latest.direction] ?? "opening";
|
|
55
|
+
}, [ceremonies]);
|
|
56
|
+
|
|
57
|
+
const phaseFramings: Record<string, string> = {
|
|
58
|
+
opening: "🔥 We are in the Opening phase — setting intentions and acknowledging relationships",
|
|
59
|
+
council: "🔥 We are in the Council phase — deepening understanding through dialogue",
|
|
60
|
+
integration: "🔥 We are in the Integration phase — weaving learnings together",
|
|
61
|
+
closure: "🔥 We are in the Closure phase — honoring what has been shared",
|
|
62
|
+
};
|
|
63
|
+
const phases = ["opening", "council", "integration", "closure"];
|
|
64
|
+
const nextPhase = phases[(phases.indexOf(currentPhase) + 1) % 4];
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="p-6 max-w-5xl mx-auto">
|
|
68
|
+
<div className="flex items-center justify-between mb-6">
|
|
69
|
+
<div>
|
|
70
|
+
<h1 className="text-2xl font-bold">Ceremonies</h1>
|
|
71
|
+
<p className="text-sm text-muted-foreground">{ceremonies.length} ceremony logs</p>
|
|
72
|
+
</div>
|
|
73
|
+
<button onClick={() => setShowForm(!showForm)} className="px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium">+ Log Ceremony</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="mb-6 p-4 rounded-lg border bg-card/50 backdrop-blur">
|
|
77
|
+
<div className="flex items-center gap-3">
|
|
78
|
+
<span className="text-2xl">🔥</span>
|
|
79
|
+
<div className="flex-1">
|
|
80
|
+
<p className="text-sm font-medium">{phaseFramings[currentPhase]}</p>
|
|
81
|
+
<p className="text-xs text-muted-foreground mt-1">Next phase: <span className="capitalize">{nextPhase}</span></p>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex gap-1">
|
|
84
|
+
{phases.map((p) => (
|
|
85
|
+
<div key={p} className="w-2 h-2 rounded-full" style={{ backgroundColor: p === currentPhase ? "var(--color-primary)" : "var(--color-muted-foreground)", opacity: p === currentPhase ? 1 : 0.3 }} title={p} />
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="flex gap-2 mb-6 flex-wrap">
|
|
92
|
+
<div className="flex gap-1">
|
|
93
|
+
<button onClick={() => setFilterDir("all")} className={`px-3 py-1 rounded-full text-xs font-medium border ${filterDir === "all" ? "bg-secondary" : ""}`}>All</button>
|
|
94
|
+
{directions.map((d) => (
|
|
95
|
+
<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: DIRECTION_COLORS[d] } : {}}>
|
|
96
|
+
{d}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="px-3 py-1 rounded-md border bg-background text-xs">
|
|
101
|
+
<option value="all">All Types</option>
|
|
102
|
+
{cTypes.map((t) => <option key={t} value={t}>{t.replace("_", " ")}</option>)}
|
|
103
|
+
</select>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{showForm && (
|
|
107
|
+
<form onSubmit={logCeremony} className="mb-6 p-4 border rounded-lg bg-card grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
108
|
+
<select name="type" required className="px-3 py-2 rounded-md border bg-background text-sm">
|
|
109
|
+
{cTypes.map((t) => <option key={t} value={t}>{CEREMONY_ICONS[t]} {t.replace("_", " ")}</option>)}
|
|
110
|
+
</select>
|
|
111
|
+
<select name="direction" required className="px-3 py-2 rounded-md border bg-background text-sm">
|
|
112
|
+
{directions.map((d) => <option key={d} value={d}>{d}</option>)}
|
|
113
|
+
</select>
|
|
114
|
+
<input name="participants" placeholder="Participants (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm" />
|
|
115
|
+
<input name="medicines" placeholder="Medicines used (comma-separated)" className="px-3 py-2 rounded-md border bg-background text-sm" />
|
|
116
|
+
<textarea name="intention" placeholder="Intention" rows={2} className="px-3 py-2 rounded-md border bg-background text-sm sm:col-span-2" />
|
|
117
|
+
<input name="context" placeholder="Research context (optional)" className="px-3 py-2 rounded-md border bg-background text-sm" />
|
|
118
|
+
<button type="submit" className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm">Log Ceremony</button>
|
|
119
|
+
</form>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<div className="space-y-3">
|
|
123
|
+
{ceremonies.length === 0 && <div className="text-center py-12 text-muted-foreground"><p>No ceremonies found. Log your first ceremony to begin.</p></div>}
|
|
124
|
+
{ceremonies.map((c) => (
|
|
125
|
+
<div key={c.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50 transition-colors"
|
|
126
|
+
style={{ borderTopColor: DIRECTION_COLORS[c.direction], borderTopWidth: 3 }} onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}>
|
|
127
|
+
<div className="p-4 flex items-center justify-between">
|
|
128
|
+
<div className="flex items-center gap-3">
|
|
129
|
+
<span className="text-2xl">{CEREMONY_ICONS[c.type]}</span>
|
|
130
|
+
<div>
|
|
131
|
+
<div className="font-medium text-sm capitalize">{c.type.replace("_", " ")}</div>
|
|
132
|
+
<div className="text-xs text-muted-foreground">{c.direction} · {new Date(c.timestamp).toLocaleString()}</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="flex items-center gap-2">
|
|
136
|
+
{c.participants.length > 0 && <span className="px-2 py-0.5 rounded-full bg-secondary text-xs">{c.participants.length} participant{c.participants.length !== 1 ? "s" : ""}</span>}
|
|
137
|
+
{c.medicines_used.length > 0 && <span className="px-2 py-0.5 rounded-full bg-secondary text-xs">🌿 {c.medicines_used.length}</span>}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
{expandedId === c.id && (
|
|
141
|
+
<div className="px-4 pb-4 space-y-3 border-t pt-3">
|
|
142
|
+
{c.intentions.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Intentions</div>{c.intentions.map((int, i) => <p key={i} className="text-sm">{int}</p>)}</div>}
|
|
143
|
+
{c.participants.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Participants</div><div className="flex flex-wrap gap-1">{c.participants.map((p) => <span key={p} className="px-2 py-0.5 rounded bg-secondary text-xs">{p}</span>)}</div></div>}
|
|
144
|
+
{c.medicines_used.length > 0 && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Medicines</div><div className="flex flex-wrap gap-1">{c.medicines_used.map((m) => <span key={m} className="px-2 py-0.5 rounded bg-secondary text-xs">🌿 {m}</span>)}</div></div>}
|
|
145
|
+
{c.research_context && <div><div className="text-xs font-medium text-muted-foreground uppercase mb-1">Research Context</div><p className="text-sm">{c.research_context}</p></div>}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default function CeremoniesPage() {
|
|
156
|
+
return (
|
|
157
|
+
<Suspense fallback={<div className="p-6 text-center text-muted-foreground">Loading ceremonies...</div>}>
|
|
158
|
+
<CeremoniesContent />
|
|
159
|
+
</Suspense>
|
|
160
|
+
);
|
|
161
|
+
}
|
package/app/globals.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
@import "tailwindcss";
|
|
3
|
+
@import "../src/ui-components/src/tokens.css";
|
|
4
|
+
|
|
5
|
+
@theme inline {
|
|
6
|
+
--color-background: oklch(0.145 0 0);
|
|
7
|
+
--color-foreground: oklch(0.985 0 0);
|
|
8
|
+
--color-card: oklch(0.185 0 0);
|
|
9
|
+
--color-card-foreground: oklch(0.985 0 0);
|
|
10
|
+
--color-popover: oklch(0.185 0 0);
|
|
11
|
+
--color-popover-foreground: oklch(0.985 0 0);
|
|
12
|
+
--color-primary: oklch(0.785 0.115 85);
|
|
13
|
+
--color-primary-foreground: oklch(0.145 0 0);
|
|
14
|
+
--color-secondary: oklch(0.269 0 0);
|
|
15
|
+
--color-secondary-foreground: oklch(0.985 0 0);
|
|
16
|
+
--color-muted: oklch(0.269 0 0);
|
|
17
|
+
--color-muted-foreground: oklch(0.708 0 0);
|
|
18
|
+
--color-accent: oklch(0.269 0 0);
|
|
19
|
+
--color-accent-foreground: oklch(0.985 0 0);
|
|
20
|
+
--color-destructive: oklch(0.396 0.141 25.723);
|
|
21
|
+
--color-destructive-foreground: oklch(0.985 0 0);
|
|
22
|
+
--color-border: oklch(0.3 0 0);
|
|
23
|
+
--color-input: oklch(0.3 0 0);
|
|
24
|
+
--color-ring: oklch(0.785 0.115 85);
|
|
25
|
+
--radius: 0.625rem;
|
|
26
|
+
|
|
27
|
+
--color-east: oklch(0.85 0.15 85);
|
|
28
|
+
--color-south: oklch(0.55 0.22 25);
|
|
29
|
+
--color-west: oklch(0.25 0.05 270);
|
|
30
|
+
--color-north: oklch(0.92 0.01 0);
|
|
31
|
+
|
|
32
|
+
--color-node-human: oklch(0.72 0.15 55);
|
|
33
|
+
--color-node-land: oklch(0.6 0.16 145);
|
|
34
|
+
--color-node-spirit: oklch(0.6 0.18 300);
|
|
35
|
+
--color-node-ancestor: oklch(0.48 0.09 55); /* deep copper — #8a5a2a */
|
|
36
|
+
--color-node-future: oklch(0.7 0.12 230);
|
|
37
|
+
--color-node-knowledge: oklch(0.83 0.18 110); /* lime-gold — #c8d42a */
|
|
38
|
+
|
|
39
|
+
--font-sans: 'Nunito', system-ui, -apple-system, sans-serif;
|
|
40
|
+
--font-display: 'Stereohead', 'Nunito', system-ui, sans-serif;
|
|
41
|
+
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@layer base {
|
|
45
|
+
* {
|
|
46
|
+
@apply border-border;
|
|
47
|
+
}
|
|
48
|
+
body {
|
|
49
|
+
@apply bg-background text-foreground;
|
|
50
|
+
font-family: var(--font-sans);
|
|
51
|
+
}
|
|
52
|
+
h1, h2 {
|
|
53
|
+
font-family: var(--font-display);
|
|
54
|
+
font-weight: normal;
|
|
55
|
+
letter-spacing: 0.01em;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@keyframes pulse-center {
|
|
60
|
+
0%, 100% { opacity: 0.6; transform: scale(1); }
|
|
61
|
+
50% { opacity: 1; transform: scale(1.08); }
|
|
62
|
+
}
|
|
63
|
+
@keyframes glow {
|
|
64
|
+
0%, 100% { filter: brightness(1); }
|
|
65
|
+
50% { filter: brightness(1.3); }
|
|
66
|
+
}
|
|
67
|
+
.animate-pulse-center { animation: pulse-center 3s ease-in-out infinite; }
|
|
68
|
+
.animate-glow { animation: glow 2s ease-in-out infinite; }
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react";
|
|
4
|
+
import { type RelationalNode, type RelationalEdge, DIRECTION_COLORS } from "@/lib/types";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
|
|
7
|
+
interface GraphNode {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
type: string;
|
|
11
|
+
direction?: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface GraphLink {
|
|
17
|
+
source: string;
|
|
18
|
+
target: string;
|
|
19
|
+
label: string;
|
|
20
|
+
ceremonyHonored: boolean;
|
|
21
|
+
strength: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function GraphPage() {
|
|
25
|
+
const [nodes, setNodes] = useState<GraphNode[]>([]);
|
|
26
|
+
const [links, setLinks] = useState<GraphLink[]>([]);
|
|
27
|
+
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
const [showLabels, setShowLabels] = useState(true);
|
|
30
|
+
|
|
31
|
+
const loadData = useCallback(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const [nodesRes, edgesRes] = await Promise.all([fetch("/api/nodes"), fetch("/api/edges")]);
|
|
34
|
+
const nodesData: RelationalNode[] = await nodesRes.json();
|
|
35
|
+
const edgesData: RelationalEdge[] = await edgesRes.json();
|
|
36
|
+
|
|
37
|
+
// Position nodes by direction on a circular layout
|
|
38
|
+
const CX = 350, CY = 300, R = 220;
|
|
39
|
+
const dirAngles: Record<string, number> = { east: 0, south: 90, west: 180, north: 270 };
|
|
40
|
+
|
|
41
|
+
const graphNodes = nodesData.map((n, i) => {
|
|
42
|
+
const baseAngle = n.direction ? dirAngles[n.direction] ?? 0 : (360 * i) / nodesData.length;
|
|
43
|
+
const jitter = (Math.random() - 0.5) * 60;
|
|
44
|
+
const angle = ((baseAngle + jitter) * Math.PI) / 180;
|
|
45
|
+
const r = R * (0.5 + Math.random() * 0.4);
|
|
46
|
+
return {
|
|
47
|
+
id: n.id,
|
|
48
|
+
label: n.name,
|
|
49
|
+
type: n.type,
|
|
50
|
+
direction: n.direction,
|
|
51
|
+
x: CX + r * Math.cos(angle),
|
|
52
|
+
y: CY + r * Math.sin(angle),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const graphLinks = edgesData.map((e) => ({
|
|
57
|
+
source: e.from_id,
|
|
58
|
+
target: e.to_id,
|
|
59
|
+
label: e.relationship_type,
|
|
60
|
+
ceremonyHonored: e.ceremony_honored,
|
|
61
|
+
strength: e.strength,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
setNodes(graphNodes);
|
|
65
|
+
setLinks(graphLinks);
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error("Failed to load graph data");
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => { loadData(); }, [loadData]);
|
|
74
|
+
|
|
75
|
+
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="min-h-screen bg-[#0a0a1a] text-white p-6">
|
|
79
|
+
<div className="max-w-7xl mx-auto">
|
|
80
|
+
<div className="flex items-center justify-between mb-6">
|
|
81
|
+
<div>
|
|
82
|
+
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
83
|
+
<span className="text-3xl">🔮</span> Medicine Wheel Graph
|
|
84
|
+
</h1>
|
|
85
|
+
<p className="text-gray-400 text-sm mt-1">Relational visualization across four directions</p>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex gap-2">
|
|
88
|
+
<button
|
|
89
|
+
onClick={() => setShowLabels(!showLabels)}
|
|
90
|
+
className={`px-3 py-1.5 rounded text-sm ${showLabels ? "bg-white/10" : "bg-white/5"}`}
|
|
91
|
+
>
|
|
92
|
+
Labels {showLabels ? "ON" : "OFF"}
|
|
93
|
+
</button>
|
|
94
|
+
<button onClick={loadData} className="px-3 py-1.5 rounded text-sm bg-white/5 hover:bg-white/10">↻ Refresh</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="flex gap-6">
|
|
99
|
+
<div className="flex-1 rounded-xl border border-white/10 overflow-hidden">
|
|
100
|
+
{loading ? (
|
|
101
|
+
<div className="flex items-center justify-center h-[600px] text-gray-500">Loading graph data...</div>
|
|
102
|
+
) : nodes.length === 0 ? (
|
|
103
|
+
<div className="flex flex-col items-center justify-center h-[600px] text-gray-500">
|
|
104
|
+
<span className="text-4xl mb-3">🌀</span>
|
|
105
|
+
<p>No relational nodes yet.</p>
|
|
106
|
+
<p className="text-sm mt-1">Create nodes via the Nodes page.</p>
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
<svg viewBox="0 0 700 600" className="w-full h-[600px]">
|
|
110
|
+
{/* Quadrant backgrounds */}
|
|
111
|
+
{[
|
|
112
|
+
{ dir: "east", cx: 525, cy: 300 },
|
|
113
|
+
{ dir: "south", cx: 350, cy: 475 },
|
|
114
|
+
{ dir: "west", cx: 175, cy: 300 },
|
|
115
|
+
{ dir: "north", cx: 350, cy: 125 },
|
|
116
|
+
].map(({ dir, cx, cy }) => (
|
|
117
|
+
<g key={dir}>
|
|
118
|
+
<circle cx={cx} cy={cy} r={40} fill={(DIRECTION_COLORS as any)[dir]} opacity={0.1} />
|
|
119
|
+
<text x={cx} y={cy + 4} textAnchor="middle" fill={(DIRECTION_COLORS as any)[dir]} className="text-xs font-bold capitalize" opacity={0.6}>
|
|
120
|
+
{dir}
|
|
121
|
+
</text>
|
|
122
|
+
</g>
|
|
123
|
+
))}
|
|
124
|
+
|
|
125
|
+
{/* Links */}
|
|
126
|
+
{links.map((link, i) => {
|
|
127
|
+
const source = nodeMap.get(link.source);
|
|
128
|
+
const target = nodeMap.get(link.target);
|
|
129
|
+
if (!source || !target) return null;
|
|
130
|
+
return (
|
|
131
|
+
<line
|
|
132
|
+
key={i}
|
|
133
|
+
x1={source.x} y1={source.y} x2={target.x} y2={target.y}
|
|
134
|
+
stroke={link.ceremonyHonored ? "#FFD700" : "#555"}
|
|
135
|
+
strokeWidth={1 + link.strength * 2}
|
|
136
|
+
strokeDasharray={link.ceremonyHonored ? "none" : "6,4"}
|
|
137
|
+
opacity={0.5}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
|
|
142
|
+
{/* Nodes */}
|
|
143
|
+
{nodes.map((node) => (
|
|
144
|
+
<g key={node.id} className="cursor-pointer" onClick={() => setSelectedNode(node)}>
|
|
145
|
+
<circle
|
|
146
|
+
cx={node.x} cy={node.y} r={selectedNode?.id === node.id ? 18 : 14}
|
|
147
|
+
fill={node.direction ? (DIRECTION_COLORS as any)[node.direction] || "#888" : "#888"}
|
|
148
|
+
stroke={selectedNode?.id === node.id ? "#FFD700" : "#333"}
|
|
149
|
+
strokeWidth={selectedNode?.id === node.id ? 3 : 1}
|
|
150
|
+
opacity={0.9}
|
|
151
|
+
/>
|
|
152
|
+
{showLabels && (
|
|
153
|
+
<text x={node.x} y={node.y + 26} textAnchor="middle" fill="#ccc" className="text-[10px]">
|
|
154
|
+
{node.label.length > 15 ? node.label.slice(0, 14) + "…" : node.label}
|
|
155
|
+
</text>
|
|
156
|
+
)}
|
|
157
|
+
</g>
|
|
158
|
+
))}
|
|
159
|
+
</svg>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="w-72 space-y-4">
|
|
164
|
+
<div className="rounded-xl border border-white/10 p-4">
|
|
165
|
+
<h3 className="text-sm font-semibold text-gray-400 mb-3">Directions</h3>
|
|
166
|
+
{(["east", "south", "west", "north"] as const).map((dir) => (
|
|
167
|
+
<div key={dir} className="flex items-center gap-2 mb-2">
|
|
168
|
+
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: DIRECTION_COLORS[dir] }} />
|
|
169
|
+
<span className="text-sm capitalize">{dir}</span>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{selectedNode && (
|
|
175
|
+
<div className="rounded-xl border border-white/10 p-4">
|
|
176
|
+
<h3 className="text-sm font-semibold text-gray-400 mb-2">Selected Node</h3>
|
|
177
|
+
<div className="space-y-2">
|
|
178
|
+
<div><span className="text-xs text-gray-500">Name</span><p className="font-medium">{selectedNode.label}</p></div>
|
|
179
|
+
<div><span className="text-xs text-gray-500">Type</span><p className="capitalize">{selectedNode.type}</p></div>
|
|
180
|
+
{selectedNode.direction && <div><span className="text-xs text-gray-500">Direction</span><p className="capitalize">{selectedNode.direction}</p></div>}
|
|
181
|
+
<div><span className="text-xs text-gray-500">ID</span><p className="text-xs text-gray-400 font-mono break-all">{selectedNode.id}</p></div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<div className="rounded-xl border border-white/10 p-4">
|
|
187
|
+
<h3 className="text-sm font-semibold text-gray-400 mb-3">Graph Stats</h3>
|
|
188
|
+
<div className="grid grid-cols-2 gap-3 text-center">
|
|
189
|
+
<div><p className="text-2xl font-bold">{nodes.length}</p><p className="text-xs text-gray-500">Nodes</p></div>
|
|
190
|
+
<div><p className="text-2xl font-bold">{links.length}</p><p className="text-xs text-gray-500">Relations</p></div>
|
|
191
|
+
<div><p className="text-2xl font-bold">{links.filter((l) => l.ceremonyHonored).length}</p><p className="text-xs text-gray-500">Ceremonied</p></div>
|
|
192
|
+
<div><p className="text-2xl font-bold">{new Set(nodes.map((n) => n.direction).filter(Boolean)).size}</p><p className="text-xs text-gray-500">Directions</p></div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
import { ThemeProvider } from "@/components/theme-provider";
|
|
4
|
+
import { Toaster } from "sonner";
|
|
5
|
+
import { Navigation } from "@/components/navigation";
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: "Medicine Wheel — Relational Research",
|
|
9
|
+
description: "Interactive visual layer for the Medicine Wheel — ceremonies, Four Directions, relational web, and narrative arcs",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
13
|
+
return (
|
|
14
|
+
<html lang="en" suppressHydrationWarning>
|
|
15
|
+
<body className="font-sans antialiased">
|
|
16
|
+
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} forcedTheme="dark">
|
|
17
|
+
<Navigation />
|
|
18
|
+
<main className="min-h-screen pt-16">{children}</main>
|
|
19
|
+
<Toaster richColors position="bottom-right" />
|
|
20
|
+
</ThemeProvider>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
23
|
+
);
|
|
24
|
+
}
|