311
apps/web/src/app/cartographer/page.tsx
Executable file
311
apps/web/src/app/cartographer/page.tsx
Executable file
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
|
||||
interface Exploration {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
status: string;
|
||||
completedNote?: string | null;
|
||||
minutes?: number | null;
|
||||
}
|
||||
|
||||
export default function CartographerPage() {
|
||||
const qc = useQueryClient();
|
||||
const showXpToast = useUiStore((s) => s.showXpToast);
|
||||
const showActionToast = useUiStore((s) => s.showActionToast);
|
||||
const [tab, setTab] = useState<"active" | "history">("active");
|
||||
const [completeId, setCompleteId] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const { data: desk, isLoading: deskLoading, error: deskError } = useQuery({
|
||||
queryKey: ["cartographer-desk"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/cartographer");
|
||||
if (!res.ok) throw new Error("Failed to load map desk");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: explorations = [], isLoading, error } = useQuery({
|
||||
queryKey: ["explorations"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/explorations");
|
||||
if (!res.ok) throw new Error("Failed to load explorations");
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ["explorations-history"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/explorations?history=true");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
enabled: tab === "history",
|
||||
});
|
||||
|
||||
const [generateSource, setGenerateSource] = useState<"ai" | "fallback" | "existing" | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const generate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/explorations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Generate failed");
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
return data as {
|
||||
explorations: Exploration[];
|
||||
source?: "ai" | "fallback" | "existing";
|
||||
};
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setGenerateSource(data.source ?? null);
|
||||
qc.invalidateQueries({ queryKey: ["explorations"] });
|
||||
},
|
||||
});
|
||||
|
||||
const generateNotice =
|
||||
generateSource === "existing"
|
||||
? "You still have suggested quests to review — finish or deny them before generating more."
|
||||
: generateSource === "fallback"
|
||||
? "AI unavailable — showing curated offline quest suggestions."
|
||||
: null;
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: async ({ id, action, note }: { id: string; action: string; note?: string }) => {
|
||||
const res = await fetch(`/api/explorations/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, note }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Action failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["explorations"] });
|
||||
qc.invalidateQueries({ queryKey: ["explorations-history"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["cartographer-desk"] });
|
||||
if (vars.action === "complete") showXpToast(100, "Exploration complete");
|
||||
if (data.actionEventId) {
|
||||
showActionToast(`Exploration ${vars.action}`, data.actionEventId);
|
||||
}
|
||||
setCompleteId(null);
|
||||
setNote("");
|
||||
},
|
||||
});
|
||||
|
||||
const suggested = explorations.filter((e: Exploration) => e.status === "suggested");
|
||||
const active = explorations.filter((e: Exploration) => e.status === "active");
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-4 pb-20 md:pb-4 parchment-bg min-h-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="font-bold text-lg">The Cartographer's Desk</h1>
|
||||
<button
|
||||
className="retro-btn"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={generate.isPending}
|
||||
>
|
||||
{generate.isPending ? "Generating..." : "Generate Quests"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generate.isError && (
|
||||
<p className="text-sm text-[var(--muted-rose)] mb-3">
|
||||
Could not generate quests. Check AI Health in Settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{generateNotice && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">{generateNotice}</p>
|
||||
)}
|
||||
|
||||
{generateSource === "fallback" && !generateNotice && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-3 italic">
|
||||
AI is offline — showing curated offline quest suggestions.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!deskLoading && desk && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
|
||||
{desk.domains?.map((d: { key: string; label: string; score: number }) => (
|
||||
<div key={d.key} className="retro-window-inset p-2 text-center">
|
||||
<p className="text-xs text-[var(--warm-grey)]">{d.label}</p>
|
||||
<p className="font-bold text-lg">{d.score}</p>
|
||||
<div className="skill-bar-track h-1.5 mt-1">
|
||||
<div className="skill-bar-fill h-full" style={{ width: `${d.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{desk.horizon?.intention && (
|
||||
<p className="text-sm italic mb-4 text-[var(--warm-grey)]">
|
||||
Horizon: {desk.horizon.intention}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{deskError && (
|
||||
<p className="text-sm text-[var(--muted-rose)] mb-3">Could not load life map scores.</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
||||
Optional curiosity quests — pick what interests you this week.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className={`retro-btn ${tab === "active" ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab("active")}
|
||||
>
|
||||
This Week
|
||||
</button>
|
||||
<button
|
||||
className={`retro-btn ${tab === "history" ? "retro-btn-primary" : ""}`}
|
||||
onClick={() => setTab("history")}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "active" && (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<p>Loading map...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-[var(--muted-rose)]">Failed to load explorations.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggested.map((e: Exploration) => (
|
||||
<QuestCard
|
||||
key={e.id}
|
||||
exploration={e}
|
||||
onAccept={() => action.mutate({ id: e.id, action: "accept" })}
|
||||
onDismiss={() => action.mutate({ id: e.id, action: "dismiss" })}
|
||||
/>
|
||||
))}
|
||||
{active.map((e: Exploration) => (
|
||||
<QuestCard
|
||||
key={e.id}
|
||||
exploration={e}
|
||||
active
|
||||
onComplete={() => setCompleteId(e.id)}
|
||||
/>
|
||||
))}
|
||||
{suggested.length === 0 && active.length === 0 && (
|
||||
<p className="text-sm">
|
||||
No explorations this week. Generate some quests — dismissed quests won't block new ones.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "history" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{history.map((e: Exploration) => (
|
||||
<span
|
||||
key={e.id}
|
||||
className="retro-window-inset px-3 py-1 text-xs"
|
||||
title={e.completedNote ?? ""}
|
||||
>
|
||||
✓ {e.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completeId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="retro-window p-4 w-full max-w-md">
|
||||
<p className="font-bold mb-2">What did you learn?</p>
|
||||
<input
|
||||
className="retro-window-inset w-full p-2 mb-4"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="One thing you discovered..."
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary w-full"
|
||||
onClick={() => action.mutate({ id: completeId, action: "complete", note })}
|
||||
>
|
||||
Complete Exploration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestCard({
|
||||
exploration,
|
||||
active,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onComplete,
|
||||
}: {
|
||||
exploration: Exploration;
|
||||
active?: boolean;
|
||||
onAccept?: () => void;
|
||||
onDismiss?: () => void;
|
||||
onComplete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="retro-window p-4 relative">
|
||||
<span className="absolute top-2 right-2 text-xs bg-[var(--gold-trim)] px-2 py-0.5 rounded">
|
||||
{exploration.category}
|
||||
</span>
|
||||
<h3 className="font-bold mb-1 pr-20">{exploration.title}</h3>
|
||||
<p className="text-sm mb-3">{exploration.description}</p>
|
||||
{exploration.minutes && (
|
||||
<p className="text-xs text-[var(--warm-grey)] mb-2">~{exploration.minutes} min</p>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{active ? (
|
||||
<>
|
||||
<button className="retro-btn retro-btn-primary" onClick={onComplete}>
|
||||
Complete
|
||||
</button>
|
||||
<Link
|
||||
href={`/teacher?explorationId=${exploration.id}&topic=${encodeURIComponent(exploration.title)}`}
|
||||
className="retro-btn text-xs"
|
||||
>
|
||||
Study with Teacher
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="retro-btn retro-btn-primary" onClick={onAccept}>
|
||||
Accept
|
||||
</button>
|
||||
<button className="retro-btn" onClick={onDismiss}>
|
||||
Deny quest
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user