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:
@ -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
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@ -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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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%;
|
||||
|
||||
83
src/components/ErrorBoundary.tsx
Normal file
83
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import "katex/dist/katex.min.css";
|
||||
import { Component, type ReactNode } from "react";
|
||||
// @ts-ignore
|
||||
import { BlockMath, InlineMath } from "react-katex";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>,
|
||||
);
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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!);
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
105
src/utils/arcTheme.ts
Normal 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)],
|
||||
};
|
||||
};
|
||||
@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user