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>
This commit is contained in:
2026-03-13 08:41:13 +06:00
parent ebbad9bc9e
commit e4c86d473c
34 changed files with 259 additions and 206 deletions

View File

@ -4,6 +4,12 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@700&family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&family=Sorts+Mill+Goudy:ital@0;1&display=swap"
rel="stylesheet"
/>
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
<script

View File

@ -1,5 +1,4 @@
import "katex/dist/katex.min.css";
import { Suspense, lazy } from "react";
import { Home } from "./pages/student/Home";
import {
createBrowserRouter,
@ -19,10 +18,13 @@ import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { QuestMap } from "./pages/student/QuestMap";
import { Register } from "./pages/auth/Register";
import { PracticeSheetList } from "./pages/student/practice-sheet/page";
const QuestMap = lazy(() =>
import("./pages/student/QuestMap").then((m) => ({ default: m.QuestMap })),
);
function App() {
const router = createBrowserRouter([
{
@ -63,11 +65,11 @@ function App() {
},
{
path: "quests",
element: <QuestMap />,
},
{
path: "practice/:sheetId",
element: <Pretest />,
element: (
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>
<QuestMap />
</Suspense>
),
},
{
path: "practice/targeted-practice",
@ -85,6 +87,10 @@ function App() {
path: "practice/practice-sheet",
element: <PracticeSheetList />,
},
{
path: "practice/:sheetId",
element: <Pretest />,
},
],
},
{

View File

@ -3,7 +3,6 @@ import type { QuestNode, ClaimedRewardResponse } from "../types/quest";
// ─── Styles ───────────────────────────────────────────────────────────────────
const S = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Nunito:wght@800;900&display=swap');
/* ══ FULL SCREEN OVERLAY ══ */
.com-overlay {

View File

@ -1,5 +1,4 @@
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@600;700&display=swap');
.cc-btn {
width: 100%;

View File

@ -12,7 +12,6 @@ type Props = {
};
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
.clp-wrap {
width: 100%;

View File

@ -0,0 +1,83 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Uncaught error:", error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: "'Nunito', sans-serif",
background: "#fffbf4",
padding: "2rem",
textAlign: "center",
}}
>
<h1
style={{
fontSize: "1.5rem",
fontWeight: 900,
color: "#1e1b4b",
marginBottom: "0.5rem",
}}
>
Something went wrong
</h1>
<p
style={{
fontSize: "0.9rem",
color: "#6b7280",
marginBottom: "1.5rem",
}}
>
An unexpected error occurred. Please try refreshing the page.
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: "0.7rem 1.4rem",
borderRadius: "100px",
border: "none",
background: "linear-gradient(135deg, #7c3aed, #a855f7)",
color: "white",
fontFamily: "'Nunito', sans-serif",
fontSize: "0.88rem",
fontWeight: 800,
cursor: "pointer",
}}
>
Reload page
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -17,7 +17,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
import { PredictedScoreCard } from "./PredictedScoreCard";
import { ChestOpenModal } from "./ChestOpenModal";
import { generateArcTheme } from "../pages/student/QuestMap";
import { generateArcTheme } from "../utils/arcTheme";
import { InventoryButton } from "./InventoryButton";
// ─── Requirement helpers ──────────────────────────────────────────────────────
@ -43,7 +43,6 @@ const REQ_LABEL: Record<string, string> = {
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
/* ════ SHARED ANIMATION ════ */
@keyframes hcIn {

View File

@ -8,7 +8,6 @@ import { InventoryModal } from "./InventoryModal";
// ─── Styles ───────────────────────────────────────────────────────────────────
const BTN_STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@800;900&family=Cinzel:wght@700&display=swap');
/* ── Inventory trigger button ── */
.inv-btn {

View File

@ -12,7 +12,6 @@ import { api } from "../utils/api";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ OVERLAY ══ */
.inv-overlay {

View File

@ -24,7 +24,6 @@ const UUID_REGEX =
const isVideoLesson = (id: string) => UUID_REGEX.test(id);
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');
.lm-content {
font-family: 'Nunito', sans-serif;
@ -156,6 +155,7 @@ export const LessonModal = ({
onOpenChange,
}: LessonModalProps) => {
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const [loading, setLoading] = useState(false);
const [lesson, setLesson] = useState<LessonDetails | null>(null);
@ -196,12 +196,6 @@ export const LessonModal = ({
setLoading(true);
try {
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) throw new Error("No auth storage");
const {
// @ts-ignore
state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) throw new Error("No token");
// @ts-ignore

View File

@ -83,7 +83,6 @@ const useCountUp = (target: number, duration = 900) => {
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.psc-card {
background: white;

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { X, Lock } from "lucide-react";
import type { QuestNode, QuestArc } from "../types/quest";
// Re-use the same theme generator as QuestMap so island colours are consistent
import { generateArcTheme } from "../pages/student/QuestMap";
import { generateArcTheme } from "../utils/arcTheme";
// ─── Requirement helpers (mirrors QuestMap / InfoHeader) ──────────────────────
const REQ_LABEL: Record<string, string> = {
@ -28,7 +28,6 @@ const reqIcon = (type: string): string =>
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ OVERLAY ══ */
.qnm-overlay {

View File

@ -1,3 +1,4 @@
import "katex/dist/katex.min.css";
import { Component, type ReactNode } from "react";
// @ts-ignore
import { BlockMath, InlineMath } from "react-katex";

View File

@ -188,7 +188,6 @@ const highlightText = (text: string, query: string) => {
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.so-overlay {
position: fixed; inset: 0; z-index: 50;

View File

@ -2,9 +2,12 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { ErrorBoundary } from "./components/ErrorBoundary.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);

View File

@ -9,7 +9,6 @@ interface LocationState {
}
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');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -18,7 +18,6 @@ import {
import { api } from "../../utils/api";
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');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -19,7 +19,6 @@ const DOTS = [
];
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; }
@ -312,6 +311,7 @@ const PAGE_SIZE = 6;
export const Home = () => {
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const navigate = useNavigate();
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
@ -337,14 +337,8 @@ export const Home = () => {
};
const fetch = async () => {
if (!user) return;
if (!user || !token) return;
try {
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
state: { token },
} = JSON.parse(authStorage);
if (!token) return;
const sheets = await api.getPracticeSheets(token, 1, 10);
setPracticeSheets(sheets.data);
sort(sheets.data);

View File

@ -31,7 +31,6 @@ const DOTS = [
// ─── Styles ───────────────────────────────────────────────────────────────────
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; }
@ -465,6 +464,7 @@ const VideoCard = ({ lesson, index, searchQuery, onClick }: VideoCardProps) => (
// ─── Component ────────────────────────────────────────────────────────────────
export const Lessons = () => {
const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token);
// Video lessons from API — typed as Lesson[]
const [allVideos, setAllVideos] = useState<Lesson[]>([]);
@ -486,17 +486,9 @@ export const Lessons = () => {
useEffect(() => {
const fetchVideos = async () => {
if (!user) return;
if (!user || !token) return;
try {
setLessonLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
// @ts-ignore
state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) return;
const response = await api.fetchLessonVideos(token);
// response matches LessonsResponse: { data: Lesson[], pagination: ... }
setAllVideos(response.data);

View File

@ -12,7 +12,6 @@ const DOTS = [
];
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; }

View File

@ -17,7 +17,6 @@ const DOTS = [
];
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; }

View File

@ -7,6 +7,7 @@ import type {
import { useQuestStore } from "../../stores/useQuestStore";
import { useAuthStore } from "../../stores/authStore";
import { api } from "../../utils/api";
import { generateArcTheme, mkRng, strToSeed, type ArcTheme } from "../../utils/arcTheme";
import { QuestNodeModal } from "../../components/QuestNodeModal";
import { ChestOpenModal } from "../../components/ChestOpenModal";
import { InfoHeader } from "../../components/InfoHeader";
@ -21,22 +22,7 @@ const TOP_PAD = 80;
const ROW_H = 520; // vertical step per island (increased for more separation)
// ─── Seeded RNG ───────────────────────────────────────────────────────────────
const mkRng = (seed: number) => {
let s = seed >>> 0;
return () => {
s += 0x6d2b79f5;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const strToSeed = (str: string) => {
let h = 5381;
for (let i = 0; i < str.length; i++)
h = (Math.imul(h, 33) ^ str.charCodeAt(i)) >>> 0;
return h;
};
// mkRng, strToSeed imported from ../../utils/arcTheme
// ─── Random island positions ──────────────────────────────────────────────────
// Generates organic-feeling positions that zigzag downward with random offsets.
@ -102,8 +88,7 @@ const generateIslandPositions = (
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&family=Cinzel:wght@700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
* { box-sizing: border-box; }
@ -601,92 +586,7 @@ const ERROR_STYLES = `
`;
// ─── Arc theme ────────────────────────────────────────────────────────────────
export interface ArcTheme {
accent: string;
accentDark: string;
bgFrom: string;
bgTo: string;
emoji: string;
terrain: { l: string; m: string; d: string; s: string };
decos: [string, string, string];
}
const DECO_SETS: [string, string, string][] = [
["🌴", "🌿", "🌴"],
["🌵", "🏺", "🌵"],
["☁️", "✨", "☁️"],
["🪨", "🌾", "🪨"],
["🍄", "🌸", "🍄"],
["🔥", "💀", "🔥"],
["❄️", "🌨️", "❄️"],
["🌺", "🦜", "🌺"],
];
const hslToHex = (h: number, s: number, l: number) => {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h * 12) % 12;
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
return Math.round(255 * c)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
};
export const generateArcTheme = (arc: QuestArc): ArcTheme => {
const rng = mkRng(strToSeed(arc.id));
const anchors = [150, 165, 180, 200, 230, 260];
const baseHue =
anchors[Math.floor(rng() * anchors.length)] + (rng() - 0.5) * 8;
const satBase = 0.48 + rng() * 0.18;
const satTerrain = Math.min(0.8, satBase + 0.12);
const accentLightL = 0.48 + rng() * 0.12;
const accentDarkL = 0.22 + rng() * 0.06;
const bgFromL = 0.04 + rng() * 0.06;
const bgToL = 0.1 + rng() * 0.06;
const accent = hslToHex(baseHue, satBase, accentLightL);
const accentDark = hslToHex(
baseHue + (rng() * 6 - 3),
Math.max(0.35, satBase - 0.08),
accentDarkL,
);
const bgFrom = hslToHex(
baseHue + (rng() * 10 - 5),
0.1 + rng() * 0.06,
bgFromL,
);
const bgTo = hslToHex(baseHue + (6 + rng() * 12), 0.08 + rng() * 0.06, bgToL);
const tL = hslToHex(
baseHue + 10 + rng() * 6,
Math.min(0.85, satTerrain),
0.36 + rng() * 0.08,
);
const tM = hslToHex(
baseHue + (rng() * 6 - 3),
Math.min(0.72, satTerrain - 0.06),
0.24 + rng() * 0.06,
);
const tD = hslToHex(
baseHue + (rng() * 8 - 4),
Math.max(0.38, satBase - 0.18),
0.1 + rng() * 0.04,
);
const sd = parseInt(tD.slice(1, 3), 16);
const sg = parseInt(tD.slice(3, 5), 16);
const sb = parseInt(tD.slice(5, 7), 16);
const emojis = ["🌿", "🌲", "🌳", "🌺", "🪨", "🍄", "🌵"];
const emoji = emojis[Math.floor(rng() * emojis.length)];
return {
accent,
accentDark,
bgFrom,
bgTo,
emoji,
terrain: { l: tL, m: tM, d: tD, s: `rgba(${sd},${sg},${sb},0.6)` },
decos: DECO_SETS[Math.floor(rng() * DECO_SETS.length)],
};
};
// ArcTheme, generateArcTheme imported from ../../utils/arcTheme
const themeCache = new Map<string, ArcTheme>();
const getArcTheme = (arc: QuestArc): ArcTheme => {

View File

@ -31,7 +31,6 @@ const DOTS = [
];
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; }
@ -415,6 +414,7 @@ const EmptyState = () => (
export const Rewards = () => {
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const [time, setTime] = useState("today");
const [activeTab, setActiveTab] = useState<TabId>("xp");
const [leaderboard, setLeaderboard] = useState<Leaderboard | undefined>();
@ -431,20 +431,15 @@ export const Rewards = () => {
useEffect(() => {
const fetchData = async () => {
if (!user) return;
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
} | null;
const token = parsed?.state?.token;
if (!token) return;
if (!user || !token) return;
try {
setLoading(true);
const timeframe =
activeTab === "streaks" ? "all_time" : (TIME_MAP[time] ?? "daily");
const response = await api.fetchLeaderboard(
token,
activeTab,
TIME_MAP[time] ?? "daily",
timeframe,
);
setLeaderboard(response);
// ✅ FIX 1: Guard against null user_rank before accessing its properties
@ -563,9 +558,13 @@ export const Rewards = () => {
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rw-filter-btn">
{formatTimeLabel(time)} <ChevronDown size={13} />
<DropdownMenuTrigger asChild disabled={activeTab === "streaks"}>
<button
className="rw-filter-btn"
style={activeTab === "streaks" ? { opacity: 0.5, cursor: "not-allowed" } : undefined}
>
{activeTab === "streaks" ? "All Time" : formatTimeLabel(time)}{" "}
<ChevronDown size={13} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@ -57,8 +57,7 @@ const QUEST_NAV_ITEMS = NAV_ITEMS.map((item) =>
);
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Cinzel:wght@700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy:ital@0;1&display=swap');
/* ══ DEFAULT dock (cream frosted glass) ══ */
.sl-dock-wrap {

View File

@ -22,7 +22,6 @@ const DOTS = [
];
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; }
@ -281,6 +280,7 @@ const STYLES = `
export const Drills = () => {
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const navigate = useNavigate();
const [direction, setDirection] = useState<1 | -1>(1);
@ -319,16 +319,9 @@ export const Drills = () => {
useEffect(() => {
const fetchAllTopics = async () => {
if (!user) return;
if (!user || !token) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
} | null;
const token = parsed?.state?.token;
if (!token) return;
const response = await api.fetchAllTopics(token);
setTopics(response);
} catch (e) {

View File

@ -24,7 +24,6 @@ const DOTS = [
];
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; }

View File

@ -17,6 +17,7 @@ import {
import { useNavigate } from "react-router-dom";
import { api } from "../../../utils/api";
import { type PracticeSheet } from "../../../types/sheet";
import { useAuthStore } from "../../../stores/authStore";
/* ─────────────────────────────────────────────
Ambient decoration
@ -415,7 +416,6 @@ const EmptyState = ({ query }: { query: string }) => (
Styles
───────────────────────────────────────────── */
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; }
@ -700,6 +700,7 @@ 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);
@ -707,13 +708,8 @@ export const PracticeSheetList = () => {
const [viewMode, setViewMode] = useState<ViewMode>("compact");
useEffect(() => {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
state: { token },
} = JSON.parse(authStorage);
if (!token) return;
setLoading(true);
api
.getPracticeSheets(token, 1, 10)
.then((data) => {

View File

@ -23,7 +23,6 @@ const DOTS = [
];
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; }
@ -227,6 +226,7 @@ export const Pretest = () => {
const { setSheetId, setMode, storeDuration, setQuestionCount } =
useExamConfigStore();
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const { sheetId } = useParams<{ sheetId: string }>();
const navigate = useNavigate();
@ -246,15 +246,9 @@ export const Pretest = () => {
}
useEffect(() => {
if (!user) return;
if (!user || !token) return;
async function fetchSheet(id: string) {
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
state: { token },
} = JSON.parse(authStorage);
if (!token) return;
const data = await api.getPracticeSheetById(token, id);
const data = await api.getPracticeSheetById(token!, id);
setPracticeSheet(data);
}
fetchSheet(sheetId!);

View File

@ -8,7 +8,6 @@ import { useAuthStore } from "../../../stores/authStore";
// ─── Shared styles injected once ─────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
:root { --content-max: 1100px; }

View File

@ -91,7 +91,6 @@ const DOTS = [
// ─── Global Styles ────────────────────────────────────────────────────────────
const GLOBAL_STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }

View File

@ -66,7 +66,6 @@ const getSectionMeta = (section?: string) =>
SECTION_META[section ?? ""] ?? SECTION_META["default"];
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; }
@ -508,16 +507,9 @@ export const TargetedPractice = () => {
useEffect(() => {
const fetchAllTopics = async () => {
if (!user) return;
if (!user || !token) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
} | null;
const token = parsed?.state?.token;
if (!token) return;
const response = await api.fetchAllTopics(token);
setTopics(response);
} catch (error) {

View File

@ -68,6 +68,7 @@ export const useAuthStore = create<AuthState>()(
set({
registrationMessage: response.message,
isLoading: false,
});
return true;
@ -119,3 +120,8 @@ export const useAuthStore = create<AuthState>()(
},
),
);
/** Safe non-hook token accessor for use outside React components or in callbacks */
export function getAuthToken(): string | null {
return useAuthStore.getState().token;
}

105
src/utils/arcTheme.ts Normal file
View File

@ -0,0 +1,105 @@
import type { QuestArc } from "../types/quest";
export interface ArcTheme {
accent: string;
accentDark: string;
bgFrom: string;
bgTo: string;
emoji: string;
terrain: { l: string; m: string; d: string; s: string };
decos: [string, string, string];
}
export const mkRng = (seed: number) => {
let s = seed >>> 0;
return () => {
s += 0x6d2b79f5;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
export const strToSeed = (str: string) => {
let h = 5381;
for (let i = 0; i < str.length; i++)
h = (Math.imul(h, 33) ^ str.charCodeAt(i)) >>> 0;
return h;
};
const hslToHex = (h: number, s: number, l: number) => {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h * 12) % 12;
const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
return Math.round(255 * c)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
};
const DECO_SETS: [string, string, string][] = [
["\u{1F334}", "\u{1F33F}", "\u{1F334}"],
["\u{1F335}", "\u{1F3FA}", "\u{1F335}"],
["\u2601\uFE0F", "\u2728", "\u2601\uFE0F"],
["\u{1FAA8}", "\u{1F33E}", "\u{1FAA8}"],
["\u{1F344}", "\u{1F338}", "\u{1F344}"],
["\u{1F525}", "\u{1F480}", "\u{1F525}"],
["\u2744\uFE0F", "\u{1F328}\uFE0F", "\u2744\uFE0F"],
["\u{1F33A}", "\u{1F99C}", "\u{1F33A}"],
];
export const generateArcTheme = (arc: QuestArc): ArcTheme => {
const rng = mkRng(strToSeed(arc.id));
const anchors = [150, 165, 180, 200, 230, 260];
const baseHue =
anchors[Math.floor(rng() * anchors.length)] + (rng() - 0.5) * 8;
const satBase = 0.48 + rng() * 0.18;
const satTerrain = Math.min(0.8, satBase + 0.12);
const accentLightL = 0.48 + rng() * 0.12;
const accentDarkL = 0.22 + rng() * 0.06;
const bgFromL = 0.04 + rng() * 0.06;
const bgToL = 0.1 + rng() * 0.06;
const accent = hslToHex(baseHue, satBase, accentLightL);
const accentDark = hslToHex(
baseHue + (rng() * 6 - 3),
Math.max(0.35, satBase - 0.08),
accentDarkL,
);
const bgFrom = hslToHex(
baseHue + (rng() * 10 - 5),
0.1 + rng() * 0.06,
bgFromL,
);
const bgTo = hslToHex(baseHue + (6 + rng() * 12), 0.08 + rng() * 0.06, bgToL);
const tL = hslToHex(
baseHue + 10 + rng() * 6,
Math.min(0.85, satTerrain),
0.36 + rng() * 0.08,
);
const tM = hslToHex(
baseHue + (rng() * 6 - 3),
Math.min(0.72, satTerrain - 0.06),
0.24 + rng() * 0.06,
);
const tD = hslToHex(
baseHue + (rng() * 8 - 4),
Math.max(0.38, satBase - 0.18),
0.1 + rng() * 0.04,
);
const sd = parseInt(tD.slice(1, 3), 16);
const sg = parseInt(tD.slice(3, 5), 16);
const sb = parseInt(tD.slice(5, 7), 16);
const emojis = ["\u{1F33F}", "\u{1F332}", "\u{1F333}", "\u{1F33A}", "\u{1FAA8}", "\u{1F344}", "\u{1F335}"];
const emoji = emojis[Math.floor(rng() * emojis.length)];
return {
accent,
accentDark,
bgFrom,
bgTo,
emoji,
terrain: { l: tL, m: tM, d: tD, s: `rgba(${sd},${sg},${sb},0.6)` },
decos: DECO_SETS[Math.floor(rng() * DECO_SETS.length)],
};
};

View File

@ -15,7 +15,13 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
troika: ["troika-three-text", "troika-worker-utils"],
three: [
"three",
"@react-three/fiber",
"@react-three/drei",
"troika-three-text",
"troika-worker-utils",
],
},
},
},