Files
edbridge-scholars/src/pages/student/practice-sheet/page.tsx
pptx704 e4c86d473c fix: resolve bugs and improve frontend performance
- Fix register not resetting isLoading on success (causing login page to hang)
- Fix leaderboard streaks 400 error by forcing all_time timeframe
- Reorder routes so static paths match before dynamic practice/:sheetId
- Lazy-load QuestMap + Three.js (saves ~350KB gzip on initial load)
- Move KaTeX CSS to lazy import (only loads on math pages)
- Remove 28 duplicate Google Font @import lines from component CSS
- Add font preconnect + single stylesheet link in index.html
- Replace 8 unsafe JSON.parse(localStorage) calls with Zustand selectors
- Add global ErrorBoundary to prevent full-app crashes
- Extract arcTheme utilities to break static import cycle with QuestMap
- Merge Three.js + Troika into single chunk to fix circular dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:41:13 +06:00

1006 lines
34 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, useMemo, useState } from "react";
import {
BookOpen,
Clock,
FileText,
Layers,
LayoutGrid,
List,
Lock,
PlayCircle,
Search,
Sparkles,
Tag,
X,
Zap,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { api } from "../../../utils/api";
import { type PracticeSheet } from "../../../types/sheet";
import { useAuthStore } from "../../../stores/authStore";
/* ─────────────────────────────────────────────
Ambient decoration
───────────────────────────────────────────── */
const DOTS = [
{ size: 10, color: "#f97316", top: "7%", left: "4%", delay: "0s" },
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
{ size: 9, color: "#22c55e", top: "60%", left: "3%", delay: "0.6s" },
{ size: 12, color: "#3b82f6", top: "11%", right: "4%", delay: "1.8s" },
{ size: 7, color: "#f43f5e", top: "47%", right: "2%", delay: "0.9s" },
{ size: 9, color: "#eab308", top: "76%", right: "5%", delay: "0.4s" },
];
/* ─────────────────────────────────────────────
Helpers
───────────────────────────────────────────── */
const DIFF_META: Record<
string,
{ label: string; color: string; bg: string; border: string }
> = {
EASY: { label: "Easy", color: "#16a34a", bg: "#f0fdf4", border: "#bbf7d0" },
MEDIUM: {
label: "Medium",
color: "#d97706",
bg: "#fffbeb",
border: "#fde68a",
},
HARD: { label: "Hard", color: "#dc2626", bg: "#fff5f5", border: "#fecaca" },
};
const getDiff = (d: string) =>
DIFF_META[d?.toUpperCase()] ?? {
label: d,
color: "#6b7280",
bg: "#f9fafb",
border: "#e5e7eb",
};
const STATUS_META: Record<
string,
{ label: string; color: string; bg: string }
> = {
COMPLETED: { label: "Completed", color: "#16a34a", bg: "#f0fdf4" },
IN_PROGRESS: { label: "In Progress", color: "#d97706", bg: "#fffbeb" },
NOT_STARTED: { label: "Not Started", color: "#9ca3af", bg: "#f9fafb" },
};
const getStatus = (s: string) =>
STATUS_META[s?.toUpperCase()] ?? {
label: s ?? "—",
color: "#9ca3af",
bg: "#f9fafb",
};
const PALETTES = [
{
bg: "#fff5f5",
blob: "#fee2e2",
accent: "#fca5a5",
pop: "#ef4444",
line: "#fecaca",
},
{
bg: "#ecfeff",
blob: "#cffafe",
accent: "#67e8f9",
pop: "#06b6d4",
line: "#a5f3fc",
},
{
bg: "#f7ffe4",
blob: "#d9f99d",
accent: "#bef264",
pop: "#84cc16",
line: "#d9f99d",
},
{
bg: "#fffbeb",
blob: "#fef3c7",
accent: "#fde68a",
pop: "#f59e0b",
line: "#fcd34d",
},
];
const formatTime = (mins: number) => {
if (!mins) return "—";
if (mins < 60) return `${mins}m`;
return `${Math.floor(mins / 60)}h ${mins % 60 > 0 ? `${mins % 60}m` : ""}`.trim();
};
const initials = (name: string) =>
name
?.split(" ")
.map((w) => w[0])
.slice(0, 2)
.join("")
.toUpperCase() ?? "?";
/* ─────────────────────────────────────────────
Card illustration
───────────────────────────────────────────── */
const CardIllo = ({ index, locked }: { index: number; locked: boolean }) => {
const p = PALETTES[index % PALETTES.length];
return (
<svg
viewBox="0 0 320 90"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid slice"
style={{ width: "100%", height: "100%", display: "block" }}
>
<rect width="320" height="90" fill={p.bg} />
<circle cx="270" cy="45" r="60" fill={p.blob} opacity="0.7" />
<circle cx="290" cy="10" r="28" fill={p.accent} opacity="0.35" />
<circle cx="240" cy="80" r="20" fill={p.line} opacity="0.4" />
<line x1="0" y1="88" x2="320" y2="88" stroke={p.line} strokeWidth="1.5" />
<rect
x="18"
y="24"
width="54"
height="52"
rx="7"
fill={p.accent}
opacity="0.4"
transform="rotate(-6 45 50)"
/>
<rect
x="20"
y="20"
width="54"
height="52"
rx="7"
fill="white"
stroke={p.line}
strokeWidth="2"
transform="rotate(-2 47 46)"
/>
<rect
x="22"
y="16"
width="54"
height="52"
rx="7"
fill="white"
stroke={p.accent}
strokeWidth="2"
/>
<line
x1="30"
y1="32"
x2="66"
y2="32"
stroke={p.line}
strokeWidth="2"
strokeLinecap="round"
/>
<line
x1="30"
y1="40"
x2="72"
y2="40"
stroke={p.line}
strokeWidth="2"
strokeLinecap="round"
/>
<line
x1="30"
y1="48"
x2="60"
y2="48"
stroke={p.line}
strokeWidth="2"
strokeLinecap="round"
/>
<line
x1="30"
y1="56"
x2="68"
y2="56"
stroke={p.line}
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M30 26 l4 4 l8 -8"
stroke={p.pop}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<path
d="M130 18 l2 -5 l2 5 l5 2 l-5 2 l-2 5 l-2 -5 l-5 -2 Z"
fill={p.pop}
opacity="0.5"
/>
<path
d="M170 65 l1.5 -4 l1.5 4 l4 1.5 l-4 1.5 l-1.5 4 l-1.5 -4 l-4 -1.5 Z"
fill={p.accent}
opacity="0.6"
/>
<circle cx="115" cy="60" r="4" fill={p.pop} opacity="0.25" />
<circle cx="155" cy="22" r="3" fill={p.accent} opacity="0.35" />
{locked && (
<>
<rect width="320" height="90" fill="rgba(255,255,255,0.55)" />
<rect
x="143"
y="28"
width="34"
height="34"
rx="10"
fill={p.pop}
opacity="0.15"
/>
<path
d="M153 42 v-5 a7 7 0 0 1 14 0 v5 h-14Z"
stroke={p.pop}
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
<rect
x="149"
y="42"
width="22"
height="16"
rx="5"
fill={p.pop}
opacity="0.8"
/>
<circle cx="160" cy="50" r="2.5" fill="white" />
</>
)}
</svg>
);
};
/* ─────────────────────────────────────────────
Compact row card
───────────────────────────────────────────── */
const CompactCard = ({
sheet,
index,
onStart,
}: {
sheet: PracticeSheet;
index: number;
onStart: () => void;
}) => {
const p = PALETTES[index % PALETTES.length];
const status = getStatus(sheet.user_status);
return (
<div
className={`ps-compact-card${sheet.is_locked ? " locked" : ""}`}
style={{ animationDelay: `${0.03 + index * 0.025}s` }}
onClick={sheet.is_locked ? undefined : onStart}
>
<div className="ps-compact-bar" style={{ background: p.pop }} />
<div className="ps-compact-main">
<p className="ps-compact-title">
{sheet.title}
{sheet.is_locked && (
<Lock
size={12}
style={{ marginLeft: 5, color: "#9ca3af", flexShrink: 0 }}
/>
)}
</p>
<div className="ps-compact-meta">
<span
className="ps-compact-chip"
style={{ background: status.bg, color: status.color }}
>
<Sparkles size={9} />
{status.label}
</span>
{sheet.time_limit > 0 && (
<span className="ps-compact-chip ps-compact-chip-neutral">
<Clock size={9} />
{formatTime(sheet.time_limit)}
</span>
)}
{sheet.modules_count > 0 && (
<span className="ps-compact-chip ps-compact-chip-neutral">
<Layers size={9} />
{sheet.modules_count} module{sheet.modules_count !== 1 ? "s" : ""}
</span>
)}
</div>
</div>
{sheet.is_locked ? (
<div className="ps-compact-lock-pill">
<Lock size={12} />
</div>
) : (
<button
className="ps-compact-start-btn"
style={{ background: p.pop }}
onClick={(e) => {
e.stopPropagation();
onStart();
}}
>
<PlayCircle size={15} />
<span>Start</span>
</button>
)}
</div>
);
};
/* ─────────────────────────────────────────────
Skeletons
───────────────────────────────────────────── */
const SkeletonCard = () => (
<div className="ps-card ps-skeleton">
<div className="ps-card-illo ps-skel-block" />
<div className="ps-card-body" style={{ gap: "0.75rem" }}>
<div className="ps-skel-line" style={{ width: "70%", height: 16 }} />
<div className="ps-skel-line" style={{ width: "90%", height: 12 }} />
<div style={{ display: "flex", gap: 8 }}>
<div
className="ps-skel-line"
style={{ width: 60, height: 22, borderRadius: 100 }}
/>
<div
className="ps-skel-line"
style={{ width: 60, height: 22, borderRadius: 100 }}
/>
</div>
<div className="ps-skel-line" style={{ width: "50%", height: 12 }} />
</div>
</div>
);
const SkeletonCompact = () => (
<div
className="ps-compact-card ps-skeleton"
style={{ pointerEvents: "none" }}
>
<div className="ps-compact-bar ps-skel-block" style={{ width: 5 }} />
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 8,
padding: "0.7rem 0",
}}
>
<div className="ps-skel-line" style={{ width: "50%", height: 13 }} />
<div style={{ display: "flex", gap: 6 }}>
<div
className="ps-skel-line"
style={{ width: 70, height: 18, borderRadius: 100 }}
/>
<div
className="ps-skel-line"
style={{ width: 50, height: 18, borderRadius: 100 }}
/>
</div>
</div>
<div
className="ps-skel-line"
style={{ width: 68, height: 30, borderRadius: 100, marginRight: 0 }}
/>
</div>
);
/* ─────────────────────────────────────────────
Empty state
───────────────────────────────────────────── */
const EmptyState = ({ query }: { query: string }) => (
<div className="ps-empty">
<svg width="72" height="72" viewBox="0 0 72 72" fill="none">
<circle cx="36" cy="36" r="36" fill="#f3f4f6" />
<path
d="M24 28 h24 v20 a4 4 0 0 1-4 4 H28 a4 4 0 0 1-4-4 V28Z"
fill="#e5e7eb"
/>
<rect x="28" y="34" width="16" height="2.5" rx="1.25" fill="#d1d5db" />
<rect x="28" y="39" width="12" height="2.5" rx="1.25" fill="#d1d5db" />
<rect x="28" y="44" width="14" height="2.5" rx="1.25" fill="#d1d5db" />
</svg>
<p className="ps-empty-title">
{query ? `No sheets match "${query}"` : "No practice sheets yet"}
</p>
<p className="ps-empty-sub">Try a different search or check back later.</p>
</div>
);
/* ─────────────────────────────────────────────
Styles
───────────────────────────────────────────── */
const STYLES = `
:root { --content-max: 1100px; }
.ps-screen {
min-height: 100vh;
background: #fffbf4;
font-family: 'Nunito', sans-serif;
position: relative;
overflow-x: hidden;
}
@media (min-width: 768px) {
.ps-screen { padding-left: calc(17rem + 1.25rem); }
}
.ps-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
.ps-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:psWobble1 14s ease-in-out infinite; }
.ps-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:psWobble2 16s ease-in-out infinite; }
.ps-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:psWobble1 18s ease-in-out infinite reverse; }
.ps-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:psWobble2 12s ease-in-out infinite; }
@keyframes psWobble1 {
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 psWobble2 {
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);}
}
.ps-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:psFloat 7s ease-in-out infinite; }
@keyframes psFloat {
0%,100%{transform:translateY(0) rotate(0deg);}
50%{transform:translateY(-12px) rotate(180deg);}
}
.ps-inner {
position:relative;z-index:1;
max-width:580px;margin:0 auto;
padding:2rem 1.25rem 4rem;
display:flex;flex-direction:column;gap:1.5rem;
}
@media(min-width:900px){
.ps-inner { max-width:var(--content-max);padding:3rem 1.5rem 6rem; }
.ps-blob-1 { left:calc((100vw - var(--content-max)) / 2 - 120px);top:-120px;width:300px;height:300px; }
.ps-blob-2 { left:calc((100vw - var(--content-max)) / 2 + 20px);bottom:-80px;width:220px;height:220px; }
.ps-blob-3 { right:calc((100vw - var(--content-max)) / 2 - 40px);top:10%;width:260px;height:260px; }
.ps-blob-4 { right:calc((100vw - var(--content-max)) / 2 + 10px);bottom:6%;width:180px;height:180px; }
}
@keyframes psPopIn {
from{opacity:0;transform:scale(0.92) translateY(12px);}
to{opacity:1;transform:scale(1) translateY(0);}
}
.ps-anim { animation:psPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
.ps-anim-1 { animation-delay:0.05s; }
.ps-anim-2 { animation-delay:0.10s; }
.ps-anim-3 { animation-delay:0.15s; }
/* Page header */
.ps-page-header { display:flex;flex-direction:column;gap:0.3rem; }
.ps-eyebrow {
font-size:0.65rem;font-weight:800;letter-spacing:0.18em;
text-transform:uppercase;color:#f59e0b;
display:flex;align-items:center;gap:0.4rem;
}
.ps-title {
font-size:clamp(1.6rem,5vw,2.1rem);font-weight:900;
color:#1e1b4b;letter-spacing:-0.03em;line-height:1.1;
}
.ps-subtitle {
font-family:'Nunito Sans',sans-serif;
font-size:0.88rem;font-weight:600;color:#9ca3af;margin-top:0.2rem;
}
/* Search */
.ps-search-wrap { position:relative; }
.ps-search-icon {
position:absolute;left:1rem;top:50%;transform:translateY(-50%);
color:#9ca3af;pointer-events:none;
}
.ps-search-input {
width:100%;box-sizing:border-box;
background:white;border:2.5px solid #e9d5ff;border-radius:100px;
padding:0.75rem 3rem 0.75rem 2.8rem;
font-family:'Nunito',sans-serif;font-size:0.92rem;font-weight:700;
color:#1e1b4b;outline:none;
box-shadow:0 4px 14px rgba(0,0,0,0.04);
transition:border-color 0.15s,box-shadow 0.15s;
}
.ps-search-input::placeholder { color:#c4b5fd;font-weight:600; }
.ps-search-input:focus { border-color:#a855f7;box-shadow:0 4px 20px rgba(168,85,247,0.15); }
.ps-search-clear {
position:absolute;right:0.85rem;top:50%;transform:translateY(-50%);
background:#f3f4f6;border:none;border-radius:50%;width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
cursor:pointer;color:#6b7280;transition:background 0.15s;
}
.ps-search-clear:hover { background:#e5e7eb; }
/* ── Toolbar ── */
.ps-toolbar {
display:flex;align-items:center;justify-content:space-between;gap:0.75rem;
}
.ps-results-meta {
font-size:0.78rem;font-weight:800;color:#9ca3af;letter-spacing:0.04em;
}
.ps-results-meta span { color:#7c3aed; }
.ps-view-toggle {
display:flex;align-items:center;
background:white;border:2.5px solid #e9d5ff;border-radius:100px;
padding:3px;gap:2px;
box-shadow:0 2px 8px rgba(0,0,0,0.04);
flex-shrink:0;
}
.ps-toggle-btn {
display:flex;align-items:center;gap:0.3rem;
padding:0.3rem 0.75rem;border-radius:100px;border:none;
cursor:pointer;
font-family:'Nunito',sans-serif;font-size:0.72rem;font-weight:800;
color:#9ca3af;background:transparent;
transition:background 0.15s,color 0.15s,box-shadow 0.15s;
white-space:nowrap;
}
.ps-toggle-btn.active {
background:linear-gradient(135deg,#a855f7,#7c3aed);
color:white;
box-shadow:0 2px 8px rgba(124,58,237,0.25);
}
.ps-toggle-btn:not(.active):hover { color:#7c3aed;background:#f5f3ff; }
/* ── Standard grid ── */
.ps-grid { display:grid;grid-template-columns:1fr;gap:1rem; }
@media(min-width:520px){ .ps-grid { grid-template-columns:1fr 1fr; } }
@media(min-width:900px){ .ps-grid { grid-template-columns:repeat(3,1fr); } }
.ps-card {
background:white;border:2.5px solid #f3f4f6;border-radius:22px;
overflow:hidden;cursor:pointer;
box-shadow:0 4px 14px rgba(0,0,0,0.04);
display:flex;flex-direction:column;
transition:transform 0.15s ease,box-shadow 0.15s ease,border-color 0.15s ease;
animation:psPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
}
.ps-card:hover { transform:translateY(-4px);box-shadow:0 14px 32px rgba(0,0,0,0.09);border-color:#e9d5ff; }
.ps-card:active { transform:translateY(1px);box-shadow:0 3px 8px rgba(0,0,0,0.06); }
.ps-card.locked { opacity:0.8; }
.ps-card-illo { width:100%;height:90px;flex-shrink:0;position:relative;overflow:hidden; }
.ps-card-body { padding:1rem 1.1rem 1rem;display:flex;flex-direction:column;gap:0.55rem;flex:1; }
.ps-card-title-row { display:flex;align-items:flex-start;justify-content:space-between;gap:0.5rem; }
.ps-card-title { font-size:0.97rem;font-weight:900;color:#1e1b4b;line-height:1.3;flex:1; }
.ps-lock-icon { color:#9ca3af;flex-shrink:0;margin-top:1px; }
.ps-card-desc {
font-family:'Nunito Sans',sans-serif;
font-size:0.75rem;font-weight:600;color:#9ca3af;line-height:1.45;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
}
.ps-pill-row { display:flex;flex-wrap:wrap;gap:0.4rem; }
.ps-pill {
display:inline-flex;align-items:center;gap:0.28rem;
padding:0.25rem 0.65rem;border-radius:100px;
font-size:0.7rem;font-weight:800;border:1.5px solid transparent;white-space:nowrap;
}
.ps-stats-row { display:flex;flex-wrap:wrap;gap:0.75rem;margin-top:0.1rem; }
.ps-stat { display:flex;align-items:center;gap:0.3rem;font-size:0.72rem;font-weight:700;color:#6b7280; }
.ps-stat svg { flex-shrink:0; }
.ps-tags-row { display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.1rem;align-items:center; }
.ps-tag { background:#f3f4f6;border-radius:6px;padding:0.18rem 0.5rem;font-size:0.65rem;font-weight:700;color:#6b7280;white-space:nowrap; }
.ps-card-footer {
display:flex;align-items:center;justify-content:space-between;
padding:0.6rem 1.1rem 0.85rem;border-top:1.5px solid #f3f4f6;margin-top:auto;
}
.ps-author { display:flex;align-items:center;gap:0.4rem;font-size:0.7rem;font-weight:700;color:#9ca3af; }
.ps-author-avatar {
width:22px;height:22px;border-radius:50%;
background:linear-gradient(135deg,#a855f7,#7c3aed);
display:flex;align-items:center;justify-content:center;
font-size:0.6rem;font-weight:800;color:white;flex-shrink:0;
}
.ps-cta-arrow { font-size:0.72rem;font-weight:800;color:#7c3aed;display:flex;align-items:center;gap:0.2rem;transition:gap 0.2s ease; }
.ps-card:hover .ps-cta-arrow { gap:0.45rem; }
/* ── Compact list ── */
.ps-compact-list { display:flex;flex-direction:column;gap:0.45rem; }
.ps-compact-card {
background:white;border:2px solid #f3f4f6;border-radius:16px;
display:flex;align-items:center;gap:0.85rem;
padding:0 1rem 0 0;
overflow:hidden;cursor:pointer;
box-shadow:0 2px 8px rgba(0,0,0,0.04);
transition:transform 0.12s ease,box-shadow 0.12s ease,border-color 0.12s ease;
animation:psPopIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
min-height:56px;
}
.ps-compact-card:hover {
transform:translateX(3px);
box-shadow:0 6px 20px rgba(0,0,0,0.08);
border-color:#e9d5ff;
}
.ps-compact-card:active { transform:translateX(1px); }
.ps-compact-card.locked { opacity:0.75;cursor:default; }
.ps-compact-bar {
width:5px;align-self:stretch;flex-shrink:0;border-radius:0;
}
.ps-compact-main {
flex:1;display:flex;flex-direction:column;gap:0.28rem;
padding:0.6rem 0;min-width:0;
}
.ps-compact-title {
font-size:0.87rem;font-weight:900;color:#1e1b4b;
line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
display:flex;align-items:center;gap:4px;
}
.ps-compact-meta {
display:flex;flex-wrap:wrap;align-items:center;gap:0.28rem;
}
.ps-compact-chip {
display:inline-flex;align-items:center;gap:0.2rem;
padding:0.16rem 0.45rem;border-radius:100px;
font-size:0.63rem;font-weight:800;white-space:nowrap;
}
.ps-compact-chip-neutral { background:#f3f4f6;color:#6b7280; }
.ps-compact-start-btn {
display:inline-flex;align-items:center;gap:0.3rem;
padding:0.38rem 0.85rem;border-radius:100px;border:none;
cursor:pointer;
font-family:'Nunito',sans-serif;font-size:0.72rem;font-weight:800;color:white;
flex-shrink:0;
box-shadow:0 3px 0 rgba(0,0,0,0.12);
transition:transform 0.1s ease,box-shadow 0.1s ease;
}
.ps-compact-start-btn:hover { transform:translateY(-1px);box-shadow:0 4px 0 rgba(0,0,0,0.12); }
.ps-compact-start-btn:active { transform:translateY(1px);box-shadow:0 1px 0 rgba(0,0,0,0.12); }
.ps-compact-lock-pill {
display:inline-flex;align-items:center;justify-content:center;
width:30px;height:30px;border-radius:50%;
background:#f3f4f6;color:#9ca3af;flex-shrink:0;
}
/* Skeleton */
.ps-skeleton { pointer-events:none; }
.ps-skel-block, .ps-skel-line {
background:linear-gradient(90deg,#f3f4f6 25%,#e5e7eb 50%,#f3f4f6 75%);
background-size:200% 100%;
animation:psSkelShimmer 1.4s ease-in-out infinite;
border-radius:8px;
}
@keyframes psSkelShimmer {
0%{background-position:200% 0;}
100%{background-position:-200% 0;}
}
/* Empty */
.ps-empty {
display:flex;flex-direction:column;align-items:center;
gap:0.75rem;padding:3rem 1rem;text-align:center;grid-column:1/-1;
}
.ps-empty-title { font-size:1rem;font-weight:900;color:#1e1b4b; }
.ps-empty-sub { font-family:'Nunito Sans',sans-serif;font-size:0.82rem;font-weight:600;color:#9ca3af; }
/* Error */
.ps-error {
background:#fff5f5;border:2px solid #fecaca;border-radius:16px;
padding:1rem 1.25rem;font-size:0.85rem;font-weight:700;color:#dc2626;
display:flex;align-items:center;gap:0.6rem;
}
`;
/* ─────────────────────────────────────────────
Main component
───────────────────────────────────────────── */
type ViewMode = "standard" | "compact";
export const PracticeSheetList = () => {
const navigate = useNavigate();
const token = useAuthStore((state) => state.token);
const [sheets, setSheets] = useState<PracticeSheet[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("compact");
useEffect(() => {
if (!token) return;
setLoading(true);
api
.getPracticeSheets(token, 1, 10)
.then((data) => {
const normalized: PracticeSheet[] = Array.isArray(data)
? data
: Array.isArray(data?.data)
? data.data
: Array.isArray(data?.results)
? data.results
: Array.isArray(data?.practice_sheets)
? data.practice_sheets
: [];
setSheets(normalized);
setError(null);
})
.catch(() => setError("Couldn't load practice sheets. Please try again."))
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
if (!query.trim()) return sheets;
const q = query.toLowerCase();
return sheets.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.description?.toLowerCase().includes(q) ||
s.topics?.some((t) => t.name.toLowerCase().includes(q)) ||
s.difficulty?.toLowerCase().includes(q),
);
}, [sheets, query]);
const handleStart = (sheet: PracticeSheet) => {
if (!sheet.is_locked) navigate(`/student/practice/${sheet.id}`);
};
return (
<div className="ps-screen">
<style>{STYLES}</style>
<div className="ps-blob ps-blob-1" />
<div className="ps-blob ps-blob-2" />
<div className="ps-blob ps-blob-3" />
<div className="ps-blob ps-blob-4" />
{DOTS.map((d, i) => (
<div
key={i}
className="ps-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: (d as any).left,
right: (d as any).right,
animationDelay: d.delay,
animationDuration: `${5 + i * 0.5}s`,
} as React.CSSProperties
}
/>
))}
<div className="ps-inner">
{/* Page heading */}
<div className="ps-page-header ps-anim ps-anim-1">
<p className="ps-eyebrow">
<FileText size={11} />
Practice Sheets
</p>
<h1 className="ps-title">
Pick your sheet,
<br />
own your score 📄
</h1>
<p className="ps-subtitle">
Curated question sets designed to sharpen specific skills work
through them at your own pace.
</p>
</div>
{/* Search */}
<div className="ps-search-wrap ps-anim ps-anim-2">
<Search size={17} className="ps-search-icon" />
<input
className="ps-search-input"
type="text"
placeholder="Search by title, topic, or difficulty…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query && (
<button className="ps-search-clear" onClick={() => setQuery("")}>
<X size={14} />
</button>
)}
</div>
{/* Toolbar: count + view toggle */}
{!loading && !error && (
<div className="ps-toolbar ps-anim ps-anim-3">
<p className="ps-results-meta">
{query ? (
<>
<span>{filtered.length}</span> result
{filtered.length !== 1 ? "s" : ""} for "{query}"
</>
) : (
<>
<span>{sheets.length}</span> sheet
{sheets.length !== 1 ? "s" : ""} available
</>
)}
</p>
<div className="ps-view-toggle">
<button
className={`ps-toggle-btn${viewMode === "standard" ? " active" : ""}`}
onClick={() => setViewMode("standard")}
>
<LayoutGrid size={13} />
Standard
</button>
<button
className={`ps-toggle-btn${viewMode === "compact" ? " active" : ""}`}
onClick={() => setViewMode("compact")}
>
<List size={13} />
Compact
</button>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="ps-error">
<span></span> {error}
</div>
)}
{/* Standard grid */}
{viewMode === "standard" && (
<div className="ps-grid">
{loading ? (
Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)
) : filtered.length === 0 ? (
<EmptyState query={query} />
) : (
filtered.map((sheet, idx) => {
const diff = getDiff(sheet.difficulty);
const status = getStatus(sheet.user_status);
return (
<div
key={sheet.id}
className={`ps-card${sheet.is_locked ? " locked" : ""}`}
style={{ animationDelay: `${0.05 + idx * 0.04}s` }}
onClick={() => handleStart(sheet)}
>
<div className="ps-card-illo">
<CardIllo index={idx} locked={sheet.is_locked} />
</div>
<div className="ps-card-body">
<div className="ps-card-title-row">
<p className="ps-card-title">{sheet.title}</p>
{sheet.is_locked && (
<Lock size={14} className="ps-lock-icon" />
)}
</div>
{sheet.description && (
<p className="ps-card-desc">{sheet.description}</p>
)}
<div className="ps-pill-row">
<span
className="ps-pill"
style={{
background: diff.bg,
color: diff.color,
borderColor: diff.border,
}}
>
<Zap size={10} />
{diff.label}
</span>
<span
className="ps-pill"
style={{
background: status.bg,
color: status.color,
borderColor: "transparent",
}}
>
<Sparkles size={10} />
{status.label}
</span>
</div>
<div className="ps-stats-row">
{sheet.questions_count > 0 && (
<span className="ps-stat">
<BookOpen size={12} />
{sheet.questions_count} Q
</span>
)}
{sheet.modules_count > 0 && (
<span className="ps-stat">
<Layers size={12} />
{sheet.modules_count} module
{sheet.modules_count !== 1 ? "s" : ""}
</span>
)}
{sheet.time_limit > 0 && (
<span className="ps-stat">
<Clock size={12} />
{formatTime(sheet.time_limit)}
</span>
)}
</div>
{sheet.topics?.length > 0 && (
<div className="ps-tags-row">
<Tag
size={10}
style={{
color: "#c4b5fd",
marginTop: 2,
flexShrink: 0,
}}
/>
{sheet.topics.slice(0, 4).map((t) => (
<span key={t.id} className="ps-tag">
{t.name}
</span>
))}
{sheet.topics.length > 4 && (
<span className="ps-tag">
+{sheet.topics.length - 4}
</span>
)}
</div>
)}
</div>
<div className="ps-card-footer">
<div className="ps-author">
<div className="ps-author-avatar">
{initials(sheet.created_by?.name ?? "")}
</div>
<span>{sheet.created_by?.name ?? "Unknown"}</span>
</div>
{sheet.is_locked ? (
<span
style={{
fontSize: "0.7rem",
fontWeight: 800,
color: "#9ca3af",
}}
>
🔒 Locked
</span>
) : (
<span className="ps-cta-arrow">Start </span>
)}
</div>
</div>
);
})
)}
</div>
)}
{/* Compact list */}
{viewMode === "compact" && (
<div className="ps-compact-list">
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<SkeletonCompact key={i} />
))
) : filtered.length === 0 ? (
<EmptyState query={query} />
) : (
filtered.map((sheet, idx) => (
<CompactCard
key={sheet.id}
sheet={sheet}
index={idx}
onStart={() => handleStart(sheet)}
/>
))
)}
</div>
)}
</div>
</div>
);
};