@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
|
@@ -40,7 +40,7 @@ export default function AccountabilityPage() {
|
|
|
40
40
|
|
|
41
41
|
return (
|
|
42
42
|
<div className="p-6 max-w-5xl mx-auto">
|
|
43
|
-
<h1 className="
|
|
43
|
+
<h1 className="mw-h1 mb-2">Accountability & frameworks</h1>
|
|
44
44
|
<p className="text-sm text-muted-foreground mb-6">Relational accountability metrics and Indigenous research frameworks</p>
|
|
45
45
|
|
|
46
46
|
{loading && <div className="text-center py-16 text-muted-foreground">Loading accountability metrics…</div>}
|
|
@@ -49,26 +49,26 @@ export default function AccountabilityPage() {
|
|
|
49
49
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
50
50
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
51
51
|
<p className="text-3xl font-bold">{nodes.length}</p>
|
|
52
|
-
<p className="text-sm text-muted-foreground">Relations
|
|
52
|
+
<p className="text-sm text-muted-foreground">Relations mapped</p>
|
|
53
53
|
<p className="text-xs text-muted-foreground">{nodes.length} nodes · {edges.length} edges</p>
|
|
54
54
|
</div>
|
|
55
55
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
56
56
|
<p className="text-3xl font-bold">{ceremonies.length}</p>
|
|
57
|
-
<p className="text-sm text-muted-foreground">Ceremonies
|
|
57
|
+
<p className="text-sm text-muted-foreground">Ceremonies logged</p>
|
|
58
58
|
</div>
|
|
59
59
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
60
60
|
<p className="text-3xl font-bold">{beats.length}</p>
|
|
61
|
-
<p className="text-sm text-muted-foreground">Narrative
|
|
61
|
+
<p className="text-sm text-muted-foreground">Narrative beats</p>
|
|
62
62
|
</div>
|
|
63
63
|
<div className="p-4 rounded-lg border bg-card text-center">
|
|
64
|
-
<p className="text-3xl font-bold
|
|
65
|
-
<p className="text-sm text-muted-foreground">Relations
|
|
64
|
+
<p className="text-3xl font-bold" style={{ color: "var(--mw-wilson-high)" }}>{ceremoniedCount > 0 ? "✓" : "○"}</p>
|
|
65
|
+
<p className="text-sm text-muted-foreground">Relations ceremonied</p>
|
|
66
66
|
<p className="text-xs text-muted-foreground">{ceremoniedCount}/{edges.length} edges ceremonied</p>
|
|
67
67
|
</div>
|
|
68
68
|
</div>
|
|
69
69
|
|
|
70
70
|
{/* Frameworks */}
|
|
71
|
-
<h2 className="
|
|
71
|
+
<h2 className="mw-h2 mb-4">Indigenous research frameworks</h2>
|
|
72
72
|
<div className="space-y-3">
|
|
73
73
|
{resources.map((res) => (
|
|
74
74
|
<div key={res.id} className="border rounded-lg bg-card overflow-hidden cursor-pointer hover:border-ring/50"
|
package/app/api/edges/route.ts
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createProvider,
|
|
5
|
+
detectProvider,
|
|
6
|
+
EdgeNotFoundError,
|
|
7
|
+
} from "@medicine-wheel/storage-provider";
|
|
8
|
+
|
|
9
|
+
const EdgeCreateSchema = z.object({
|
|
10
|
+
from_id: z.string({ required_error: "from_id is required" }).trim().min(1),
|
|
11
|
+
to_id: z.string({ required_error: "to_id is required" }).trim().min(1),
|
|
12
|
+
relationship_type: z
|
|
13
|
+
.string({ required_error: "relationship_type is required" })
|
|
14
|
+
.trim()
|
|
15
|
+
.min(1, "cannot be empty"),
|
|
16
|
+
strength: z.number().min(0).max(1).optional(),
|
|
17
|
+
ceremony_honored: z.boolean().optional(),
|
|
18
|
+
obligations: z.array(z.string()).optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const EdgePatchSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
relationship_type: z.string().trim().min(1, "cannot be empty").optional(),
|
|
24
|
+
strength: z.number().min(0).max(1).optional(),
|
|
25
|
+
ceremony_honored: z.boolean().optional(),
|
|
26
|
+
obligations: z.array(z.string()).optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict()
|
|
29
|
+
.refine((patch) => Object.keys(patch).length > 0, {
|
|
30
|
+
message:
|
|
31
|
+
"Provide at least one field to change: relationship_type, strength, ceremony_honored, or obligations.",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Relations are identified by their pair of nodes (from → to).
|
|
36
|
+
* Accepts ?from=&to=, ?id=<from>:<to>, or from_id/to_id in the body.
|
|
37
|
+
*/
|
|
38
|
+
function resolvePair(
|
|
39
|
+
request: Request,
|
|
40
|
+
body: Record<string, unknown> | null,
|
|
41
|
+
): { from: string; to: string } | null {
|
|
42
|
+
const { searchParams } = new URL(request.url);
|
|
43
|
+
let from = searchParams.get("from");
|
|
44
|
+
let to = searchParams.get("to");
|
|
45
|
+
|
|
46
|
+
const composite = searchParams.get("id");
|
|
47
|
+
if ((!from || !to) && composite && composite.includes(":")) {
|
|
48
|
+
const separator = composite.indexOf(":");
|
|
49
|
+
from = composite.slice(0, separator);
|
|
50
|
+
to = composite.slice(separator + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if ((!from || !to) && body) {
|
|
54
|
+
if (!from && typeof body.from_id === "string") from = body.from_id;
|
|
55
|
+
if (!to && typeof body.to_id === "string") to = body.to_id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return from && to ? { from, to } : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pairRequired() {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{
|
|
64
|
+
error: "Relation not identified — nothing was changed.",
|
|
65
|
+
hint: "Identify the relation with ?from=<node-id>&to=<node-id> (or ?id=<from>:<to>).",
|
|
66
|
+
},
|
|
67
|
+
{ status: 400 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function relationNotFound(from: string, to: string) {
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{
|
|
74
|
+
error: `No relation exists from ${from} to ${to}.`,
|
|
75
|
+
hint: "Relations are directional — check the from/to order, or refresh the relations list.",
|
|
76
|
+
},
|
|
77
|
+
{ status: 404 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function unexpected(error: unknown) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
84
|
+
}
|
|
3
85
|
|
|
4
86
|
export async function GET() {
|
|
5
87
|
try {
|
|
@@ -7,30 +89,131 @@ export async function GET() {
|
|
|
7
89
|
const edges = await store.getAllEdges();
|
|
8
90
|
return NextResponse.json(edges);
|
|
9
91
|
} catch (error: unknown) {
|
|
10
|
-
|
|
11
|
-
return NextResponse.json({ error: message }, { status: 500 });
|
|
92
|
+
return unexpected(error);
|
|
12
93
|
}
|
|
13
94
|
}
|
|
14
95
|
|
|
15
96
|
export async function POST(request: Request) {
|
|
16
97
|
try {
|
|
98
|
+
const body = await request.json().catch(() => null);
|
|
99
|
+
const parsed = EdgeCreateSchema.safeParse(body);
|
|
100
|
+
if (!parsed.success) {
|
|
101
|
+
return NextResponse.json(
|
|
102
|
+
{
|
|
103
|
+
error: "Invalid relation — nothing was created.",
|
|
104
|
+
issues: parsed.error.issues.map(
|
|
105
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
{ status: 400 },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (parsed.data.from_id === parsed.data.to_id) {
|
|
113
|
+
return NextResponse.json(
|
|
114
|
+
{
|
|
115
|
+
error: "A relation needs two distinct nodes.",
|
|
116
|
+
hint: "Choose a different node for one end of the relation.",
|
|
117
|
+
},
|
|
118
|
+
{ status: 400 },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
17
122
|
const store = await createProvider();
|
|
18
|
-
|
|
19
|
-
|
|
123
|
+
|
|
124
|
+
// Relational accountability: both ends must exist before a thread is woven.
|
|
125
|
+
const [fromNode, toNode] = await Promise.all([
|
|
126
|
+
store.getNode(parsed.data.from_id),
|
|
127
|
+
store.getNode(parsed.data.to_id),
|
|
128
|
+
]);
|
|
129
|
+
const missing = [
|
|
130
|
+
...(fromNode ? [] : [parsed.data.from_id]),
|
|
131
|
+
...(toNode ? [] : [parsed.data.to_id]),
|
|
132
|
+
];
|
|
133
|
+
if (missing.length > 0) {
|
|
134
|
+
return NextResponse.json(
|
|
135
|
+
{
|
|
136
|
+
error: `Cannot weave a relation to a node that does not exist: ${missing.join(", ")}.`,
|
|
137
|
+
hint: "Create the node first on /nodes, then weave the relation.",
|
|
138
|
+
},
|
|
139
|
+
{ status: 404 },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const replaced = Boolean(await store.getEdge(parsed.data.from_id, parsed.data.to_id));
|
|
144
|
+
|
|
20
145
|
const edge = {
|
|
21
|
-
from_id:
|
|
22
|
-
to_id:
|
|
23
|
-
relationship_type:
|
|
24
|
-
strength:
|
|
25
|
-
ceremony_honored:
|
|
26
|
-
obligations:
|
|
146
|
+
from_id: parsed.data.from_id,
|
|
147
|
+
to_id: parsed.data.to_id,
|
|
148
|
+
relationship_type: parsed.data.relationship_type,
|
|
149
|
+
strength: parsed.data.strength ?? 0.5,
|
|
150
|
+
ceremony_honored: parsed.data.ceremony_honored ?? false,
|
|
151
|
+
obligations: parsed.data.obligations ?? [],
|
|
27
152
|
created_at: new Date().toISOString(),
|
|
28
153
|
};
|
|
29
|
-
|
|
154
|
+
|
|
30
155
|
await store.createEdge(edge);
|
|
31
|
-
return NextResponse.json(
|
|
156
|
+
return NextResponse.json(
|
|
157
|
+
{ success: true, edge, replaced, provider: detectProvider() },
|
|
158
|
+
{ status: 201 },
|
|
159
|
+
);
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
return unexpected(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function PATCH(request: Request) {
|
|
166
|
+
try {
|
|
167
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
168
|
+
const pair = resolvePair(request, body);
|
|
169
|
+
if (!pair) return pairRequired();
|
|
170
|
+
|
|
171
|
+
// from_id/to_id in the body only identify the relation — strip before validating the patch.
|
|
172
|
+
const patchBody = body ? { ...body } : {};
|
|
173
|
+
delete patchBody.from_id;
|
|
174
|
+
delete patchBody.to_id;
|
|
175
|
+
|
|
176
|
+
const parsed = EdgePatchSchema.safeParse(patchBody);
|
|
177
|
+
if (!parsed.success) {
|
|
178
|
+
return NextResponse.json(
|
|
179
|
+
{
|
|
180
|
+
error: "Invalid relation patch — nothing was changed.",
|
|
181
|
+
issues: parsed.error.issues.map(
|
|
182
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
{ status: 400 },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const store = await createProvider();
|
|
190
|
+
const edge = await store.updateEdge(pair.from, pair.to, parsed.data);
|
|
191
|
+
return NextResponse.json({ success: true, edge, provider: detectProvider() });
|
|
192
|
+
} catch (error: unknown) {
|
|
193
|
+
if (error instanceof EdgeNotFoundError) {
|
|
194
|
+
return relationNotFound(error.fromId, error.toId);
|
|
195
|
+
}
|
|
196
|
+
return unexpected(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function DELETE(request: Request) {
|
|
201
|
+
try {
|
|
202
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
203
|
+
const pair = resolvePair(request, body);
|
|
204
|
+
if (!pair) return pairRequired();
|
|
205
|
+
|
|
206
|
+
const store = await createProvider();
|
|
207
|
+
await store.deleteEdge(pair.from, pair.to);
|
|
208
|
+
return NextResponse.json({
|
|
209
|
+
success: true,
|
|
210
|
+
deleted: { from_id: pair.from, to_id: pair.to },
|
|
211
|
+
provider: detectProvider(),
|
|
212
|
+
});
|
|
32
213
|
} catch (error: unknown) {
|
|
33
|
-
|
|
34
|
-
|
|
214
|
+
if (error instanceof EdgeNotFoundError) {
|
|
215
|
+
return relationNotFound(error.fromId, error.toId);
|
|
216
|
+
}
|
|
217
|
+
return unexpected(error);
|
|
35
218
|
}
|
|
36
219
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DirectionNameSchema, NodeTypeSchema } from "@medicine-wheel/ontology-core";
|
|
4
|
+
import {
|
|
5
|
+
createProvider,
|
|
6
|
+
detectProvider,
|
|
7
|
+
NodeNotFoundError,
|
|
8
|
+
NodeHasRelationsError,
|
|
9
|
+
} from "@medicine-wheel/storage-provider";
|
|
10
|
+
|
|
11
|
+
// `direction: null` releases the node from its direction.
|
|
12
|
+
const NodePatchSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
name: z.string().trim().min(1, "cannot be empty").optional(),
|
|
15
|
+
type: NodeTypeSchema.optional(),
|
|
16
|
+
description: z.string().optional(),
|
|
17
|
+
direction: DirectionNameSchema.nullable().optional(),
|
|
18
|
+
metadata: z.record(z.unknown()).optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict()
|
|
21
|
+
.refine((patch) => Object.keys(patch).length > 0, {
|
|
22
|
+
message:
|
|
23
|
+
"Provide at least one field to change: name, type, description, direction, or metadata.",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
type RouteContext = { params: Promise<{ id: string }> };
|
|
27
|
+
|
|
28
|
+
function notFound(id: string) {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{
|
|
31
|
+
error: `Node not found: ${id}`,
|
|
32
|
+
hint: "It may already be released — refresh the nodes list.",
|
|
33
|
+
},
|
|
34
|
+
{ status: 404 },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unexpected(error: unknown) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function GET(_request: Request, context: RouteContext) {
|
|
44
|
+
try {
|
|
45
|
+
const { id } = await context.params;
|
|
46
|
+
const store = await createProvider();
|
|
47
|
+
const node = await store.getNode(id);
|
|
48
|
+
if (!node) return notFound(id);
|
|
49
|
+
return NextResponse.json({ node, provider: detectProvider() });
|
|
50
|
+
} catch (error: unknown) {
|
|
51
|
+
return unexpected(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function PATCH(request: Request, context: RouteContext) {
|
|
56
|
+
try {
|
|
57
|
+
const { id } = await context.params;
|
|
58
|
+
const body = await request.json().catch(() => null);
|
|
59
|
+
const parsed = NodePatchSchema.safeParse(body);
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{
|
|
63
|
+
error: "Invalid node patch — nothing was changed.",
|
|
64
|
+
issues: parsed.error.issues.map(
|
|
65
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
{ status: 400 },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const store = await createProvider();
|
|
73
|
+
const node = await store.updateNode(id, parsed.data);
|
|
74
|
+
return NextResponse.json({ success: true, node, provider: detectProvider() });
|
|
75
|
+
} catch (error: unknown) {
|
|
76
|
+
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
|
77
|
+
return unexpected(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function DELETE(_request: Request, context: RouteContext) {
|
|
82
|
+
try {
|
|
83
|
+
const { id } = await context.params;
|
|
84
|
+
const store = await createProvider();
|
|
85
|
+
await store.deleteNode(id);
|
|
86
|
+
return NextResponse.json({ success: true, deleted: id, provider: detectProvider() });
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
|
|
89
|
+
if (error instanceof NodeHasRelationsError) {
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{
|
|
92
|
+
error: error.message,
|
|
93
|
+
relation_count: error.relationCount,
|
|
94
|
+
hint: "Release its relations on /relations, then delete the node.",
|
|
95
|
+
},
|
|
96
|
+
{ status: 409 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return unexpected(error);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/app/api/nodes/route.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DirectionNameSchema, NodeTypeSchema } from "@medicine-wheel/ontology-core";
|
|
2
4
|
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
5
|
|
|
6
|
+
const NodeCreateSchema = z.object({
|
|
7
|
+
id: z.string().trim().min(1).optional(),
|
|
8
|
+
name: z.string({ required_error: "name is required" }).trim().min(1, "cannot be empty"),
|
|
9
|
+
type: NodeTypeSchema,
|
|
10
|
+
description: z.string().optional(),
|
|
11
|
+
// Forms send "" when no direction is chosen — treat that as "no direction".
|
|
12
|
+
direction: z.preprocess(
|
|
13
|
+
(value) => (value === "" || value === null ? undefined : value),
|
|
14
|
+
DirectionNameSchema.optional(),
|
|
15
|
+
),
|
|
16
|
+
metadata: z.record(z.unknown()).optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
4
19
|
export async function GET(request: Request) {
|
|
5
20
|
try {
|
|
6
21
|
const { searchParams } = new URL(request.url);
|
|
@@ -16,10 +31,10 @@ export async function GET(request: Request) {
|
|
|
16
31
|
nodes = nodes.filter((n) => n.direction === direction);
|
|
17
32
|
}
|
|
18
33
|
|
|
19
|
-
return NextResponse.json({
|
|
20
|
-
nodes,
|
|
34
|
+
return NextResponse.json({
|
|
35
|
+
nodes,
|
|
21
36
|
provider: detectProvider(),
|
|
22
|
-
count: nodes.length
|
|
37
|
+
count: nodes.length
|
|
23
38
|
});
|
|
24
39
|
} catch (error: unknown) {
|
|
25
40
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -29,20 +44,32 @@ export async function GET(request: Request) {
|
|
|
29
44
|
|
|
30
45
|
export async function POST(request: Request) {
|
|
31
46
|
try {
|
|
47
|
+
const body = await request.json().catch(() => null);
|
|
48
|
+
const parsed = NodeCreateSchema.safeParse(body);
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{
|
|
52
|
+
error: "Invalid node — nothing was created.",
|
|
53
|
+
issues: parsed.error.issues.map(
|
|
54
|
+
(issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{ status: 400 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
const store = await createProvider();
|
|
33
|
-
const body = await request.json();
|
|
34
|
-
|
|
35
62
|
const node = {
|
|
36
|
-
id:
|
|
37
|
-
name:
|
|
38
|
-
type:
|
|
39
|
-
description:
|
|
40
|
-
direction:
|
|
41
|
-
metadata:
|
|
63
|
+
id: parsed.data.id || crypto.randomUUID(),
|
|
64
|
+
name: parsed.data.name,
|
|
65
|
+
type: parsed.data.type,
|
|
66
|
+
description: parsed.data.description || "",
|
|
67
|
+
direction: parsed.data.direction,
|
|
68
|
+
metadata: parsed.data.metadata || {},
|
|
42
69
|
created_at: new Date().toISOString(),
|
|
43
70
|
updated_at: new Date().toISOString(),
|
|
44
71
|
};
|
|
45
|
-
|
|
72
|
+
|
|
46
73
|
await store.createNode(node);
|
|
47
74
|
return NextResponse.json({ success: true, node, provider: detectProvider() }, { status: 201 });
|
|
48
75
|
} catch (error: unknown) {
|
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>
|