312 lines
10 KiB
TypeScript
Executable File
312 lines
10 KiB
TypeScript
Executable File
"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>
|
|
);
|
|
}
|