Files
edbridge-scholars/src/pages/student/drills/page.tsx

578 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { useAuthStore } from "../../../stores/authStore";
import type { Topic } from "../../../types/topic";
import { api } from "../../../utils/api";
import { ChoiceCard } from "../../../components/ChoiceCard";
import { AnimatePresence, motion } from "framer-motion";
import { slideVariants } from "../../../lib/utils";
import { ArrowLeft, Loader2, Search, Zap } from "lucide-react";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { useNavigate } from "react-router-dom";
type Step = "topic" | "review";
const STEPS: Step[] = ["topic", "review"];
const DOTS = [
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
{ size: 9, color: "#22c55e", top: "62%", left: "3%", delay: "0.6s" },
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
{ size: 7, color: "#f43f5e", top: "48%", right: "2%", delay: "0.9s" },
{ size: 9, color: "#eab308", top: "76%", right: "5%", delay: "0.4s" },
];
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.dr-screen {
min-height: 100vh;
background: #fffbf4;
font-family: 'Nunito', sans-serif;
position: relative;
overflow-x: hidden;
}
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.dr-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.dr-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
.dr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:drWobble1 14s ease-in-out infinite; }
.dr-blob-2 { width:190px;height:190px;background:#a5f3fc;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:drWobble2 16s ease-in-out infinite; }
.dr-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:drWobble1 18s ease-in-out infinite reverse; }
.dr-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:drWobble2 12s ease-in-out infinite; }
@keyframes drWobble1 {
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
}
@keyframes drWobble2 {
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
}
.dr-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:drFloat 7s ease-in-out infinite; }
@keyframes drFloat {
0%,100%{transform:translateY(0) rotate(0deg);}
50%{transform:translateY(-12px) rotate(180deg);}
}
.dr-inner {
position: relative; z-index: 1;
max-width: 560px; margin: 0 auto;
padding: 2rem 1.25rem 8rem;
display: flex; flex-direction: column; gap: 1.5rem;
}
@keyframes drPopIn {
from { opacity:0; transform:scale(0.92) translateY(12px); }
to { opacity:1; transform:scale(1) translateY(0); }
}
.dr-anim { animation: drPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
.dr-anim-1 { animation-delay:0.05s; }
.dr-anim-2 { animation-delay:0.1s; }
/* Header */
.dr-header-row { display:flex;align-items:center;gap:0.75rem; }
.dr-back-btn {
width:40px;height:40px;border-radius:50%;
background:white;border:2.5px solid #f3f4f6;
display:flex;align-items:center;justify-content:center;
cursor:pointer;box-shadow:0 3px 10px rgba(0,0,0,0.05);
transition:all 0.15s ease;flex-shrink:0;
}
.dr-back-btn:hover { border-color:#a5f3fc;background:#ecfeff; }
.dr-back-btn:active { transform:scale(0.9); }
.dr-back-btn.hidden { opacity:0;pointer-events:none; }
.dr-eyebrow {
font-size:0.62rem;font-weight:800;letter-spacing:0.16em;
text-transform:uppercase;color:#0891b2;
display:flex;align-items:center;gap:0.35rem;
}
.dr-title {
font-size:1.75rem;font-weight:900;color:#1e1b4b;
letter-spacing:-0.02em;line-height:1.15;
}
.dr-sub {
font-family:'Nunito Sans',sans-serif;
font-size:0.82rem;font-weight:600;color:#9ca3af;
margin-top:0.2rem;line-height:1.5;
}
/* Progress */
.dr-progress-wrap {
background:white;border:2.5px solid #f3f4f6;
border-radius:100px;overflow:hidden;height:8px;
box-shadow:0 2px 8px rgba(0,0,0,0.04);
}
.dr-progress-fill {
height:100%;
background:linear-gradient(90deg,#22d3ee,#0891b2);
border-radius:100px;
transition:width 0.5s cubic-bezier(0.34,1.56,0.64,1);
}
.dr-progress-labels {
display:flex;justify-content:space-between;
font-size:0.6rem;font-weight:800;letter-spacing:0.1em;
text-transform:uppercase;color:#d1d5db;
margin-top:0.35rem;padding:0 0.1rem;
}
.dr-progress-labels span.done { color:#0891b2; }
/* Step card */
.dr-step-card {
background:white;border:2.5px solid #f3f4f6;
border-radius:24px;padding:1.25rem;
box-shadow:0 4px 20px rgba(0,0,0,0.05);
display:flex;flex-direction:column;gap:1rem;
}
.dr-step-title {
font-size:1rem;font-weight:900;color:#1e1b4b;
display:flex;align-items:center;gap:0.5rem;
}
.dr-step-badge {
font-size:0.58rem;font-weight:800;letter-spacing:0.1em;
text-transform:uppercase;padding:0.2rem 0.55rem;
border-radius:100px;background:#ecfeff;
border:2px solid #a5f3fc;color:#0891b2;
}
/* Search */
.dr-search-wrap { position:relative; }
.dr-search-icon {
position:absolute;left:0.85rem;top:50%;
transform:translateY(-50%);pointer-events:none;color:#9ca3af;
}
.dr-search-input {
width:100%;padding:0.7rem 1rem 0.7rem 2.5rem;
background:#f9fafb;border:2.5px solid #f3f4f6;
border-radius:14px;
font-family:'Nunito Sans',sans-serif;
font-size:0.85rem;font-weight:600;color:#1e1b4b;
outline:none;transition:all 0.2s ease;
box-sizing:border-box;
}
.dr-search-input:focus {
border-color:#67e8f9;background:white;
box-shadow:0 0 0 3px rgba(8,145,178,0.1);
}
.dr-search-input::placeholder { color:#9ca3af; }
/* Topic grid */
.dr-topic-grid {
display:grid;grid-template-columns:1fr;
gap:0.6rem;max-height:380px;overflow-y:auto;
padding-right:0.25rem;
}
@media(min-width:480px){ .dr-topic-grid { grid-template-columns:1fr 1fr; } }
/* Loading / empty */
.dr-loading {
display:flex;align-items:center;justify-content:center;
gap:0.6rem;padding:2rem;
font-size:0.85rem;font-weight:700;color:#9ca3af;
}
.dr-spinner { animation:drSpin 0.8s linear infinite; }
@keyframes drSpin { to { transform:rotate(360deg); } }
.dr-empty { text-align:center;padding:2rem;color:#9ca3af;font-size:0.85rem;font-weight:700; }
/* Review rows */
.dr-review-row {
display:flex;align-items:flex-start;gap:0.75rem;
padding:0.85rem 0;border-bottom:2px solid #f9fafb;
}
.dr-review-row:last-child { border-bottom:none;padding-bottom:0; }
.dr-review-icon {
width:34px;height:34px;border-radius:10px;flex-shrink:0;
display:flex;align-items:center;justify-content:center;font-size:0.95rem;
}
.dr-review-label {
font-size:0.62rem;font-weight:800;letter-spacing:0.12em;
text-transform:uppercase;color:#9ca3af;
}
.dr-review-value {
font-size:0.9rem;font-weight:800;color:#1e1b4b;
margin-top:0.1rem;line-height:1.4;
}
.dr-chip-wrap { display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.35rem; }
.dr-chip {
background:#ecfeff;border:2px solid #a5f3fc;
border-radius:100px;padding:0.2rem 0.65rem;
font-size:0.72rem;font-weight:800;color:#0891b2;
}
/* Stat chips in review */
.dr-stat-row { display:flex;gap:0.6rem;margin-top:0.25rem; }
.dr-stat {
display:flex;flex-direction:column;align-items:center;
background:#f0fdff;border:2px solid #a5f3fc;
border-radius:14px;padding:0.5rem 0.85rem;flex:1;
}
.dr-stat-val { font-size:1rem;font-weight:900;color:#0891b2; }
.dr-stat-label {
font-size:0.58rem;font-weight:800;letter-spacing:0.1em;
text-transform:uppercase;color:#67e8f9;margin-top:0.1rem;
}
/* CTA bar */
.dr-cta-bar {
position: fixed;
bottom: 96px;
left: 0;
right: 0;
z-index: 5;
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
}
.dr-cta-inner {
max-width: 560px;
margin: 0 auto;
display: flex;
gap: 0.75rem;
align-items: center;
}
@media (min-width: 900px) {
.dr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
.dr-topic-grid { grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.dr-cta-bar { left: var(--sidebar-width); right: 0; }
/* Align decorative blobs relative to the centered content container */
.dr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
.dr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
.dr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
.dr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
}
.dr-next-btn {
flex:1;padding:0.9rem 1.5rem;
background:#0891b2;color:white;border:none;
border-radius:100px;cursor:pointer;
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:900;
display:flex;align-items:center;justify-content:center;gap:0.4rem;
box-shadow:0 6px 0 #0e7490,0 8px 20px rgba(8,145,178,0.28);
transition:transform 0.1s ease,box-shadow 0.1s ease;
}
.dr-next-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #0e7490,0 12px 24px rgba(8,145,178,0.32); }
.dr-next-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #0e7490; }
.dr-next-btn:disabled {
background:#e5e7eb;color:#9ca3af;cursor:not-allowed;
box-shadow:0 4px 0 #d1d5db;
}
.dr-next-btn:disabled:hover { transform:none;box-shadow:0 4px 0 #d1d5db; }
.dr-start-btn {
flex:1;padding:0.9rem 1.5rem;
background:linear-gradient(135deg,#22d3ee,#0891b2);color:white;border:none;
border-radius:100px;cursor:pointer;
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:900;
display:flex;align-items:center;justify-content:center;gap:0.4rem;
box-shadow:0 6px 0 #0e7490,0 8px 20px rgba(8,145,178,0.3);
transition:transform 0.1s ease,box-shadow 0.1s ease;
}
.dr-start-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #0e7490,0 12px 24px rgba(8,145,178,0.35); }
.dr-start-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #0e7490; }
`;
export const Drills = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
const [direction, setDirection] = useState<1 | -1>(1);
const [step, setStep] = useState<Step>("topic");
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
const [search, setSearch] = useState("");
const { storeTopics, setMode, setQuestionCount } = useExamConfigStore();
const stepIndex = STEPS.indexOf(step);
const progressPct = ((stepIndex + 1) / STEPS.length) * 100;
const toggleTopic = (topic: Topic) => {
setSelectedTopics((prev) =>
prev.some((t) => t.id === topic.id)
? prev.filter((t) => t.id !== topic.id)
: [...prev, topic],
);
};
const goNext = () => {
setDirection(1);
setStep("review");
};
const goBack = () => {
setDirection(-1);
setStep("topic");
};
function handleStartDrill() {
if (!user || !topics) return;
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
}
useEffect(() => {
const fetchAllTopics = async () => {
if (!user) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) return;
const response = await api.fetchAllTopics(token);
setTopics(response);
} catch (e) {
console.error("Failed to load topics:", e);
} finally {
setLoading(false);
}
};
fetchAllTopics();
}, [user]);
const filteredTopics = topics.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="dr-screen">
<style>{STYLES}</style>
{/* Blobs */}
<div className="dr-blob dr-blob-1" />
<div className="dr-blob dr-blob-2" />
<div className="dr-blob dr-blob-3" />
<div className="dr-blob dr-blob-4" />
{/* Dots */}
{DOTS.map((d, i) => (
<div
key={i}
className="dr-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: d.left,
right: d.right,
animationDelay: d.delay,
animationDuration: `${5 + i * 0.5}s`,
} as React.CSSProperties
}
/>
))}
<div className="dr-inner">
{/* Header */}
<div className="dr-header-row dr-anim">
<button
className={`dr-back-btn${step === "topic" ? " hidden" : ""}`}
onClick={goBack}
>
<ArrowLeft size={17} color="#6b7280" />
</button>
<div style={{ flex: 1 }}>
<p className="dr-eyebrow">
<Zap size={11} /> Drills
</p>
<h1 className="dr-title">
{step === "topic" ? "Pick your topics" : "Review & launch"}
</h1>
<p className="dr-sub">
{step === "topic"
? "Choose what you want to drill. Speed and accuracy await."
: "Everything look good? Time to drill."}
</p>
</div>
</div>
{/* Progress */}
<div className="dr-anim dr-anim-1">
<div className="dr-progress-wrap">
<div
className="dr-progress-fill"
style={{ width: `${progressPct}%` }}
/>
</div>
<div className="dr-progress-labels">
{STEPS.map((s, i) => (
<span key={s} className={i <= stepIndex ? "done" : ""}>
{s === "topic" ? "Topics" : "Review"}
</span>
))}
</div>
</div>
{/* Step content */}
<div style={{ position: "relative", overflow: "hidden" }}>
<AnimatePresence mode="wait" custom={direction}>
{/* Step 1 — Topic */}
{step === "topic" && (
<motion.div
key="topic"
custom={direction}
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
>
<div className="dr-step-card">
<div className="dr-step-title">
Choose topics
{selectedTopics.length > 0 && (
<span className="dr-step-badge">
{selectedTopics.length} selected
</span>
)}
</div>
<div className="dr-search-wrap">
<Search size={15} className="dr-search-icon" />
<input
className="dr-search-input"
placeholder="Search topics..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{loading ? (
<div className="dr-loading">
<Loader2
size={22}
color="#0891b2"
className="dr-spinner"
/>
Loading topics...
</div>
) : filteredTopics.length === 0 ? (
<p className="dr-empty">No topics match "{search}"</p>
) : (
<div className="dr-topic-grid">
{filteredTopics.map((t) => (
<ChoiceCard
key={t.id}
label={t.name}
subLabel={t.parent_name}
section={t.section}
selected={selectedTopics.some((st) => st.id === t.id)}
onClick={() => toggleTopic(t)}
/>
))}
</div>
)}
</div>
</motion.div>
)}
{/* Step 2 — Review */}
{step === "review" && (
<motion.div
key="review"
custom={direction}
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
>
<div className="dr-step-card">
<p className="dr-step-title">Your drill setup</p>
{/* Topics */}
<div className="dr-review-row">
<div
className="dr-review-icon"
style={{
background: "#ecfeff",
border: "2px solid #a5f3fc",
}}
>
📚
</div>
<div>
<p className="dr-review-label">Topics</p>
<div className="dr-chip-wrap">
{selectedTopics.map((t) => (
<span key={t.id} className="dr-chip">
{t.name}
</span>
))}
</div>
</div>
</div>
{/* Stats */}
<div className="dr-review-row">
<div
className="dr-review-icon"
style={{
background: "#ecfeff",
border: "2px solid #a5f3fc",
}}
>
</div>
<div style={{ flex: 1 }}>
<p className="dr-review-label">Session</p>
<div className="dr-stat-row">
<div className="dr-stat">
<span className="dr-stat-val">7</span>
<span className="dr-stat-label">Questions</span>
</div>
<div className="dr-stat">
<span className="dr-stat-val">~5</span>
<span className="dr-stat-label">Minutes</span>
</div>
<div className="dr-stat">
<span className="dr-stat-val"></span>
<span className="dr-stat-label">Timed</span>
</div>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* CTA bar */}
<div className="dr-cta-bar">
<div className="dr-cta-inner">
{step === "topic" && (
<button
className="dr-next-btn"
disabled={selectedTopics.length === 0}
onClick={() => {
storeTopics(selectedTopics.map((t) => t.id));
setMode("DRILL");
setQuestionCount(7);
goNext();
}}
>
Next Review
</button>
)}
{step === "review" && (
<button className="dr-start-btn" onClick={handleStartDrill}>
Start Drill
</button>
)}
</div>
</div>
</div>
);
};