- 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>
1006 lines
34 KiB
TypeScript
1006 lines
34 KiB
TypeScript
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>
|
||
);
|
||
};
|