340 lines
16 KiB
TypeScript
340 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Header } from "@/components/layout/header";
|
|
import { Footer } from "@/components/layout/footer";
|
|
import { PracticeSection } from "@/components/practice/practice-section";
|
|
import { useCollapsibleSidebar, SidebarBackdrop, SidebarToggle, sidebarAsideClass, sidebarNavClass } from "@/components/layout/collapsible-sidebar";
|
|
import { curriculum, type Unit, type Topic } from "@/lib/curriculum";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Difficulty, MathProblem } from "@/lib/problems/types";
|
|
|
|
import { generateFractionAddSubtract, generateFractionMultiply, generateFractionDivide, generateFractionOfQuantity, generateWholeFromFraction, generateFractionBodmas, generateSignedFraction } from "@/lib/problems/generators/fraction-problems";
|
|
import { generateDecimalCompareOrder, generateDecimalRounding, generateStandardForm, generateDecimalAddSubtract, generateDecimalMultiplyDivide, generateDecimalConversion } from "@/lib/problems/generators/decimal-problems";
|
|
import { generateSimplifyRatio, generateDivideInRatio, generateRatioWordProblem, generateDefineRatio, generateFractionsAndRatios } from "@/lib/problems/generators/ratio-problems";
|
|
import { generateIntegerAddSubtractMixed, generateIntegerMultiplyDivide, generateIntegerOrderCompare } from "@/lib/problems/generators/integer-problems";
|
|
import { generateDigitValue, generateBinaryToDenary, generateQuaternaryToDenary } from "@/lib/problems/generators/number-system-problems";
|
|
import { generateIdentityInverse, generateExponentEvaluate, generateDistributive } from "@/lib/problems/generators/number-properties-problems";
|
|
|
|
type UnitColor = Unit["color"];
|
|
type PracticeUnitColor = Exclude<UnitColor, "unit-7">;
|
|
type Generator = (difficulty: Difficulty) => MathProblem;
|
|
|
|
/**
|
|
* Practice for each curriculum topic. "lesson-only" topics (the coordinate grid)
|
|
* need the interactive visual, so they link to their lesson instead of a
|
|
* fill-in-the-answer generator. Keyed by `${unitSlug}/${topicSlug}`.
|
|
*/
|
|
const PRACTICE_BY_TOPIC: Record<string, Generator | "lesson-only"> = {
|
|
"unit-1-fractions/add-subtract": generateFractionAddSubtract,
|
|
"unit-1-fractions/multiply": generateFractionMultiply,
|
|
"unit-1-fractions/divide": generateFractionDivide,
|
|
"unit-1-fractions/mixed-operations": generateFractionBodmas,
|
|
"unit-1-fractions/fraction-of-quantity": generateFractionOfQuantity,
|
|
"unit-1-fractions/whole-from-fractions": generateWholeFromFraction,
|
|
"unit-2-decimals/compare-order": generateDecimalCompareOrder,
|
|
"unit-2-decimals/approximate": generateDecimalRounding,
|
|
"unit-2-decimals/standard-form": generateStandardForm,
|
|
"unit-3-decimal-operations/convert": generateDecimalConversion,
|
|
"unit-3-decimal-operations/add-subtract": generateDecimalAddSubtract,
|
|
"unit-3-decimal-operations/multiply-divide": generateDecimalMultiplyDivide,
|
|
"unit-4-ratio-proportion/define-ratio": generateDefineRatio,
|
|
"unit-4-ratio-proportion/fractions-and-ratios": generateFractionsAndRatios,
|
|
"unit-4-ratio-proportion/simplify-ratios": generateSimplifyRatio,
|
|
"unit-4-ratio-proportion/divide-in-ratio": generateDivideInRatio,
|
|
"unit-4-ratio-proportion/word-problems": generateRatioWordProblem,
|
|
"unit-5-integers/order-compare": generateIntegerOrderCompare,
|
|
"unit-5-integers/add-subtract": generateIntegerAddSubtractMixed,
|
|
"unit-5-integers/multiply-divide": generateIntegerMultiplyDivide,
|
|
"unit-5-integers/negative-fractions": generateSignedFraction,
|
|
"unit-6-number-system/place-value": generateDigitValue,
|
|
"unit-6-number-system/binary": generateBinaryToDenary,
|
|
"unit-6-number-system/quaternary": generateQuaternaryToDenary,
|
|
"unit-7-coordinates/plot-points": "lesson-only",
|
|
"unit-7-coordinates/read-coordinates": "lesson-only",
|
|
"unit-8-number-properties/identity-inverse": generateIdentityInverse,
|
|
"unit-8-number-properties/number-laws": generateDistributive,
|
|
"unit-8-number-properties/exponents": generateExponentEvaluate,
|
|
};
|
|
|
|
function practiceFor(unit: Unit, topic: Topic): Generator | "lesson-only" {
|
|
return PRACTICE_BY_TOPIC[`${unit.slug}/${topic.slug}`] ?? "lesson-only";
|
|
}
|
|
|
|
interface UnitStyle {
|
|
heading: string;
|
|
dot: string;
|
|
accent: string;
|
|
iconBg: string;
|
|
hover: string;
|
|
activeItem: string;
|
|
headingHover: string;
|
|
}
|
|
|
|
const UNIT_STYLE: Record<UnitColor, UnitStyle> = {
|
|
"unit-1": { heading: "text-unit-1-dark", dot: "bg-unit-1", accent: "border-l-unit-1", iconBg: "bg-unit-1-light text-unit-1-dark", hover: "hover:border-unit-1/40", activeItem: "bg-unit-1-light text-unit-1-dark border-unit-1/20", headingHover: "hover:bg-unit-1-light/50" },
|
|
"unit-2": { heading: "text-unit-2-dark", dot: "bg-unit-2", accent: "border-l-unit-2", iconBg: "bg-unit-2-light text-unit-2-dark", hover: "hover:border-unit-2/40", activeItem: "bg-unit-2-light text-unit-2-dark border-unit-2/20", headingHover: "hover:bg-unit-2-light/50" },
|
|
"unit-3": { heading: "text-unit-3-dark", dot: "bg-unit-3", accent: "border-l-unit-3", iconBg: "bg-unit-3-light text-unit-3-dark", hover: "hover:border-unit-3/40", activeItem: "bg-unit-3-light text-unit-3-dark border-unit-3/20", headingHover: "hover:bg-unit-3-light/50" },
|
|
"unit-4": { heading: "text-unit-4-dark", dot: "bg-unit-4", accent: "border-l-unit-4", iconBg: "bg-unit-4-light text-unit-4-dark", hover: "hover:border-unit-4/40", activeItem: "bg-unit-4-light text-unit-4-dark border-unit-4/20", headingHover: "hover:bg-unit-4-light/50" },
|
|
"unit-5": { heading: "text-unit-5-dark", dot: "bg-unit-5", accent: "border-l-unit-5", iconBg: "bg-unit-5-light text-unit-5-dark", hover: "hover:border-unit-5/40", activeItem: "bg-unit-5-light text-unit-5-dark border-unit-5/20", headingHover: "hover:bg-unit-5-light/50" },
|
|
"unit-6": { heading: "text-unit-6-dark", dot: "bg-unit-6", accent: "border-l-unit-6", iconBg: "bg-unit-6-light text-unit-6-dark", hover: "hover:border-unit-6/40", activeItem: "bg-unit-6-light text-unit-6-dark border-unit-6/20", headingHover: "hover:bg-unit-6-light/50" },
|
|
"unit-7": { heading: "text-unit-7-dark", dot: "bg-unit-7", accent: "border-l-unit-7", iconBg: "bg-unit-7-light text-unit-7-dark", hover: "hover:border-unit-7/40", activeItem: "bg-unit-7-light text-unit-7-dark border-unit-7/20", headingHover: "hover:bg-unit-7-light/50" },
|
|
"unit-8": { heading: "text-unit-8-dark", dot: "bg-unit-8", accent: "border-l-unit-8", iconBg: "bg-unit-8-light text-unit-8-dark", hover: "hover:border-unit-8/40", activeItem: "bg-unit-8-light text-unit-8-dark border-unit-8/20", headingHover: "hover:bg-unit-8-light/50" },
|
|
};
|
|
|
|
interface Selection {
|
|
unit: Unit;
|
|
topic: Topic;
|
|
}
|
|
|
|
function PlayIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ── Sidebar (mirrors the lessons sidebar) ────────────────────────────────────
|
|
|
|
function PracticeSidebar({
|
|
selected,
|
|
onSelect,
|
|
onAllTopics,
|
|
mobileOpen,
|
|
desktopCollapsed,
|
|
}: {
|
|
selected: Selection | null;
|
|
onSelect: (s: Selection) => void;
|
|
onAllTopics: () => void;
|
|
mobileOpen: boolean;
|
|
desktopCollapsed: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState<Set<number>>(() => new Set([curriculum[0].number]));
|
|
|
|
function toggle(num: number) {
|
|
setOpen((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(num)) next.delete(num);
|
|
else next.add(num);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<aside className={sidebarAsideClass(mobileOpen, desktopCollapsed)}>
|
|
<nav className={sidebarNavClass}>
|
|
<button
|
|
onClick={onAllTopics}
|
|
className={cn(
|
|
"mb-4 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-200",
|
|
selected === null
|
|
? "bg-foreground text-background shadow-[var(--shadow-sm)]"
|
|
: "text-muted hover:bg-background hover:text-foreground",
|
|
)}
|
|
>
|
|
All Topics
|
|
</button>
|
|
|
|
<div className="mb-3 h-px bg-border/60" />
|
|
|
|
{curriculum.map((unit) => {
|
|
const style = UNIT_STYLE[unit.color];
|
|
const isOpen = open.has(unit.number);
|
|
return (
|
|
<div key={unit.slug} className="mb-1">
|
|
<button
|
|
onClick={() => toggle(unit.number)}
|
|
className={cn(
|
|
"flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm font-semibold transition-all duration-200",
|
|
style.heading,
|
|
style.headingHover,
|
|
)}
|
|
>
|
|
<span className="truncate">Unit {unit.number}: {unit.title}</span>
|
|
<svg
|
|
className={cn("h-4 w-4 shrink-0 transition-transform duration-200", isOpen && "rotate-90")}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="ml-2 mt-0.5 space-y-0.5 border-l-2 border-border/40 pl-2">
|
|
{unit.topics.map((topic) => {
|
|
const isActive = selected?.unit.slug === unit.slug && selected?.topic.slug === topic.slug;
|
|
return (
|
|
<button
|
|
key={topic.slug}
|
|
onClick={() => onSelect({ unit, topic })}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm transition-all duration-200",
|
|
isActive
|
|
? cn(style.activeItem, "font-medium shadow-[var(--shadow-sm)]")
|
|
: "text-muted hover:text-foreground",
|
|
)}
|
|
>
|
|
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full transition-colors", isActive ? style.dot : "bg-border")} />
|
|
<span className="truncate">{topic.shortTitle}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
// ── Topic browser (main area, all topics) ────────────────────────────────────
|
|
|
|
function TopicBrowser({ onSelect }: { onSelect: (s: Selection) => void }) {
|
|
return (
|
|
<div className="space-y-9">
|
|
{curriculum.map((unit) => {
|
|
const style = UNIT_STYLE[unit.color];
|
|
return (
|
|
<section key={unit.slug}>
|
|
<div className="mb-3 flex items-center gap-2.5">
|
|
<span className={cn("h-3.5 w-3.5 rounded-full", style.dot)} />
|
|
<h2 className={cn("text-sm font-extrabold uppercase tracking-wider", style.heading)}>
|
|
Unit {unit.number}: {unit.title}
|
|
</h2>
|
|
<span className="rounded-full bg-border/50 px-2 py-0.5 text-[0.7rem] font-bold text-muted">
|
|
{unit.topics.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{unit.topics.map((topic) => (
|
|
<button
|
|
key={topic.slug}
|
|
onClick={() => onSelect({ unit, topic })}
|
|
className={cn(
|
|
"group flex items-center gap-3 rounded-2xl border-2 border-l-4 border-border/60 bg-surface p-4 text-left shadow-[var(--shadow-sm)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-md)]",
|
|
style.accent,
|
|
style.hover,
|
|
)}
|
|
>
|
|
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-xl", style.iconBg)}>
|
|
<PlayIcon className="h-4 w-4" />
|
|
</span>
|
|
<span className="flex-1 font-bold leading-tight">{topic.title}</span>
|
|
<svg
|
|
className="h-4 w-4 shrink-0 text-muted/40 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-muted"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2.5}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Panel for topics that are practised in the interactive lesson ────────────
|
|
|
|
function LessonOnlyPanel({ unit, topic }: { unit: Unit; topic: Topic }) {
|
|
return (
|
|
<div className="rounded-3xl border-2 border-border/60 bg-surface p-8 text-center shadow-[var(--shadow-sm)]">
|
|
<div className={cn("mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl", UNIT_STYLE[unit.color].iconBg)}>
|
|
<PlayIcon className="h-5 w-5" />
|
|
</div>
|
|
<h2 className="mb-2 text-xl font-bold">{topic.title}</h2>
|
|
<p className="mx-auto mb-6 max-w-md text-sm text-muted">
|
|
This topic uses the interactive coordinate grid, so it's best practised right in the lesson.
|
|
</p>
|
|
<Link href={`/lessons/${unit.slug}/${topic.slug}`}>
|
|
<Button variant="primary" size="md">Open the interactive lesson →</Button>
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function PracticePage() {
|
|
const [selected, setSelected] = useState<Selection | null>(null);
|
|
const { mobileOpen, desktopCollapsed, toggle, closeMobile } = useCollapsibleSidebar();
|
|
|
|
function select(s: Selection) {
|
|
setSelected(s);
|
|
closeMobile();
|
|
}
|
|
|
|
const generator = selected ? practiceFor(selected.unit, selected.topic) : null;
|
|
|
|
return (
|
|
<div className="playground-bg flex min-h-screen flex-col">
|
|
<Header />
|
|
<div className="playground-frame relative mx-auto flex w-full max-w-7xl flex-1">
|
|
<SidebarBackdrop show={mobileOpen} onClick={closeMobile} />
|
|
|
|
<PracticeSidebar
|
|
selected={selected}
|
|
onSelect={select}
|
|
onAllTopics={() => {
|
|
setSelected(null);
|
|
closeMobile();
|
|
}}
|
|
mobileOpen={mobileOpen}
|
|
desktopCollapsed={desktopCollapsed}
|
|
/>
|
|
|
|
<main className="min-w-0 flex-1 bg-white">
|
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
|
<SidebarToggle onClick={toggle} />
|
|
|
|
{!selected ? (
|
|
<>
|
|
<h1 className="mb-2 text-3xl font-extrabold tracking-tight">Practice</h1>
|
|
<p className="mb-8 text-muted">
|
|
Pick a topic and build your confidence with fresh questions each round.
|
|
</p>
|
|
<TopicBrowser onSelect={select} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button variant="secondary" size="sm" onClick={() => setSelected(null)} className="mb-6">
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
All Topics
|
|
</Button>
|
|
|
|
{generator === "lesson-only" || generator === null ? (
|
|
<LessonOnlyPanel unit={selected.unit} topic={selected.topic} />
|
|
) : (
|
|
<PracticeSection
|
|
key={`${selected.unit.slug}/${selected.topic.slug}`}
|
|
title={`Practice: ${selected.topic.title}`}
|
|
generator={generator}
|
|
unitColor={selected.unit.color as PracticeUnitColor}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|