156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { api, type Structure, type QuizCard } from "../lib/api.js";
|
|
import { useSystemStructures } from "../hooks/useSystemStructures.js";
|
|
import { Scene } from "../components/viewer/Scene.js";
|
|
import { LayerToggle } from "../components/LayerToggle.js";
|
|
import { useAtlasStore } from "../store/useAtlasStore.js";
|
|
|
|
type Deck = (Structure & { quizCard: QuizCard | null })[];
|
|
|
|
export default function QuizPage() {
|
|
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
|
const { bySlug } = useSystemStructures(visibleSystems);
|
|
const [deck, setDeck] = useState<Deck>([]);
|
|
const [index, setIndex] = useState(0);
|
|
const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
|
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
|
const [layersOpen, setLayersOpen] = useState(false);
|
|
|
|
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
|
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
|
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
|
|
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
|
|
|
|
async function loadDeck() {
|
|
const next = await api.quizNext({ limit: 10 });
|
|
setDeck(next);
|
|
setIndex(0);
|
|
setScore({ correct: 0, total: 0 });
|
|
selectStructure(null);
|
|
if (next.length > 0) {
|
|
setVisibleSystems([...new Set(next.map((s) => s.system.slug))]);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadDeck();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const current = deck[index];
|
|
const ignoreNextSelectRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!current || !selectedStructureId || feedback) return;
|
|
if (ignoreNextSelectRef.current) {
|
|
ignoreNextSelectRef.current = false;
|
|
return;
|
|
}
|
|
const isCorrect = selectedStructureId === current.id;
|
|
setFeedback(isCorrect ? "correct" : "wrong");
|
|
setScore((s) => ({ correct: s.correct + (isCorrect ? 1 : 0), total: s.total + 1 }));
|
|
api.quizReview(current.id, isCorrect ? 5 : 2).catch(console.error);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedStructureId]);
|
|
|
|
function next() {
|
|
selectStructure(null);
|
|
setFeedback(null);
|
|
if (index + 1 < deck.length) setIndex(index + 1);
|
|
else loadDeck();
|
|
}
|
|
|
|
function giveUp() {
|
|
if (!current) return;
|
|
setFeedback("wrong");
|
|
setScore((s) => ({ ...s, total: s.total + 1 }));
|
|
api.quizReview(current.id, 2).catch(console.error);
|
|
ignoreNextSelectRef.current = true;
|
|
focusOnStructure(current.slug);
|
|
selectStructure(current.id);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full relative md:grid md:grid-cols-[280px_1fr]">
|
|
<aside
|
|
className={`fixed md:static inset-y-0 left-0 z-30 w-[86vw] max-w-[300px] md:w-auto
|
|
border-r border-white/10 bg-panel p-3 flex flex-col gap-4 min-h-0 overflow-y-auto
|
|
transition-transform duration-200 md:translate-x-0
|
|
${layersOpen ? "translate-x-0" : "-translate-x-full"}`}
|
|
>
|
|
<button
|
|
onClick={() => setLayersOpen(false)}
|
|
className="md:hidden self-end text-slate-400 hover:text-white text-sm px-2 py-1"
|
|
aria-label="Fechar painel"
|
|
>
|
|
✕ Fechar
|
|
</button>
|
|
<LayerToggle />
|
|
</aside>
|
|
|
|
{layersOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-20 md:hidden"
|
|
onClick={() => setLayersOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<div className="relative h-full min-h-0">
|
|
<Scene bySlug={bySlug} />
|
|
|
|
<button
|
|
onClick={() => setLayersOpen(true)}
|
|
className="md:hidden absolute top-3 left-3 z-10 bg-panel/90 border border-white/10 rounded-lg px-3 py-2 text-xs shadow-lg"
|
|
>
|
|
☰ Camadas
|
|
</button>
|
|
|
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 bg-panel/95 border border-white/10 rounded-lg px-3 py-2.5 sm:px-5 sm:py-3 text-center shadow-lg w-[80vw] sm:w-auto sm:max-w-md">
|
|
{!current ? (
|
|
<p className="text-sm text-slate-300">
|
|
Nenhuma estrutura pendente de revisão agora. 🎉
|
|
</p>
|
|
) : (
|
|
<>
|
|
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">
|
|
Onde fica esta estrutura?
|
|
</p>
|
|
<p className="text-base sm:text-lg font-semibold text-white">{current.namePt}</p>
|
|
<p className="text-xs text-slate-500 mb-2">{current.system.namePt}</p>
|
|
|
|
{feedback === "correct" && (
|
|
<p className="text-sm text-emerald-400 mb-2">Correto! ✅</p>
|
|
)}
|
|
{feedback === "wrong" && (
|
|
<p className="text-sm text-rose-400 mb-2">Não foi essa estrutura.</p>
|
|
)}
|
|
|
|
<div className="flex gap-2 justify-center mt-2">
|
|
{feedback ? (
|
|
<button
|
|
onClick={next}
|
|
className="text-sm px-3 py-1.5 rounded bg-accent text-white"
|
|
>
|
|
Próxima
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={giveUp}
|
|
className="text-sm px-3 py-1.5 rounded bg-white/10 text-slate-200"
|
|
>
|
|
Mostrar resposta
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-500 mt-2">
|
|
{index + 1} / {deck.length} · acertos: {score.correct}/{score.total}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|