feat: guest access, pricing modal, UI polish, LaTeX prompt fix
- Remove login gate, allow guest browsing with Sign in link - Add favicon (book logo) - Add pricing modal (Free/Standard/Exam) with hover animations - Dynamic course list from DB instead of hardcoded - Enforce LaTeX in AI trio generation prompt - UI improvements: homepage animations, analytics donut chart, error book cards - Fix error book locked state for guests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,7 @@ Solution requirements:
|
|||||||
- Mark common mistakes with <div class="common-error">...</div>
|
- Mark common mistakes with <div class="common-error">...</div>
|
||||||
|
|
||||||
KaTeX formula rules:
|
KaTeX formula rules:
|
||||||
|
- CRITICAL: ALL math expressions MUST use LaTeX inside $ or $$. NEVER use Unicode symbols like ⁿ, ≥, ≠, ², ×, ∑, ∈. Use $n$, $\geq$, $\neq$, $^2$, $\times$, $\sum$, $\in$ instead.
|
||||||
- Block formula: $$ on its own line, with blank lines before and after
|
- Block formula: $$ on its own line, with blank lines before and after
|
||||||
- Inline formula: $x^2$ no line break
|
- Inline formula: $x^2$ no line break
|
||||||
- Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}}
|
- Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}}
|
||||||
@@ -174,6 +175,7 @@ Rules:
|
|||||||
- Keep each item matched to the same question_number
|
- Keep each item matched to the same question_number
|
||||||
- All text must be in English
|
- All text must be in English
|
||||||
- HTML only, KaTeX compatible
|
- HTML only, KaTeX compatible
|
||||||
|
- CRITICAL LaTeX requirement: ALL mathematical expressions MUST use LaTeX notation wrapped in $ (inline) or $$ (display block). NEVER use Unicode math symbols like ⁿ, ≥, ≠, ², ×, ∑, ∈, ⊆, etc. Instead use $n$, $\geq$, $\neq$, $^2$, $\times$, $\sum$, $\in$, $\subseteq$, etc. Every variable, number in a formula, operator, and equation must be inside $ delimiters.
|
||||||
- For MC questions, explain why the correct option is right and why the others are wrong
|
- For MC questions, explain why the correct option is right and why the others are wrong
|
||||||
- For long questions, show a complete derivation or reasoning chain
|
- For long questions, show a complete derivation or reasoning chain
|
||||||
- Use <ol> or numbered steps in solution when appropriate
|
- Use <ol> or numbered steps in solution when appropriate
|
||||||
|
|||||||
47
backend/regen_comp2711h_trio.py
Normal file
47
backend/regen_comp2711h_trio.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
One-off script: clear AI trio for COMP2711H paper and regenerate with LaTeX-enforced prompt.
|
||||||
|
Run inside the backend Docker container or locally with .env loaded.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||||
|
|
||||||
|
from app.services.supabase_client import get_supabase
|
||||||
|
from app.services.paper_processor import _resume_ai_trio
|
||||||
|
|
||||||
|
PAPER_ID = "5ee87a62-65bf-4952-be40-fcdf9ba7ca63" # COMP2711H 2025 fall final
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
sb = get_supabase()
|
||||||
|
|
||||||
|
# 1. Clear existing AI trio fields so _resume_ai_trio will regenerate them
|
||||||
|
questions = sb.table("paper_questions").select("id, question_number, solution").eq("paper_id", PAPER_ID).execute().data
|
||||||
|
print(f"Found {len(questions)} questions for paper {PAPER_ID[:8]}")
|
||||||
|
|
||||||
|
cleared = 0
|
||||||
|
for q in questions:
|
||||||
|
if q.get("solution"):
|
||||||
|
sb.table("paper_questions").update({
|
||||||
|
"knowledge_reminder": "",
|
||||||
|
"ai_hint": "",
|
||||||
|
"solution": "",
|
||||||
|
}).eq("id", q["id"]).execute()
|
||||||
|
cleared += 1
|
||||||
|
q["solution"] = None # so _resume_ai_trio picks it up
|
||||||
|
|
||||||
|
print(f"Cleared AI trio for {cleared} questions. Regenerating...")
|
||||||
|
|
||||||
|
# 2. Regenerate
|
||||||
|
await _resume_ai_trio(sb, PAPER_ID, questions)
|
||||||
|
print("Done! AI trio regenerated with LaTeX-enforced prompt.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import PricingModal from "@/components/shared/PricingModal";
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
courseCode,
|
courseCode,
|
||||||
@@ -9,8 +11,10 @@ export default function Header({
|
|||||||
paperTitle?: string;
|
paperTitle?: string;
|
||||||
}) {
|
}) {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
|
const [showPricing, setShowPricing] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className="h-14 border-b border-gray-200 bg-white flex items-center px-6 shrink-0">
|
<header className="h-14 border-b border-gray-200 bg-white flex items-center px-6 shrink-0">
|
||||||
<Link to="/" className="text-lg font-bold text-blue-600 mr-6">
|
<Link to="/" className="text-lg font-bold text-blue-600 mr-6">
|
||||||
PastPaper Master
|
PastPaper Master
|
||||||
@@ -48,6 +52,15 @@ export default function Header({
|
|||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3 pl-4 border-l border-gray-200">
|
<div className="flex items-center gap-3 pl-4 border-l border-gray-200">
|
||||||
<span className="text-xs text-gray-400">{user.email}</span>
|
<span className="text-xs text-gray-400">{user.email}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPricing(true)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200 hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||||
|
</svg>
|
||||||
|
Standard
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
className="text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100"
|
className="text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100"
|
||||||
@@ -65,5 +78,7 @@ export default function Header({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{showPricing && <PricingModal onClose={() => setShowPricing(false)} />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
203
frontend/src/components/shared/PricingModal.tsx
Normal file
203
frontend/src/components/shared/PricingModal.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function PricingModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
setTimeout(onClose, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 z-50 flex items-center justify-center transition-all duration-200 ${
|
||||||
|
visible ? "bg-black/50 backdrop-blur-sm" : "bg-black/0"
|
||||||
|
}`}
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`bg-gradient-to-b from-slate-50 to-white rounded-2xl shadow-2xl max-w-[820px] w-full mx-4 relative overflow-hidden transition-all duration-300 ${
|
||||||
|
visible ? "opacity-100 scale-100 translate-y-0" : "opacity-0 scale-95 translate-y-4"
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Decorative top bar */}
|
||||||
|
<div className="h-1.5 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500" />
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-5 right-5 w-8 h-8 flex items-center justify-center rounded-full bg-gray-100 text-gray-400 hover:bg-gray-200 hover:text-gray-600 hover:rotate-90 transition-all duration-200 text-lg"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="px-8 pt-7 pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">
|
||||||
|
Choose Your Plan
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1.5">
|
||||||
|
Unlock smarter exam prep. All prices in HKD per month.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plans grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 items-stretch">
|
||||||
|
|
||||||
|
{/* ── Free ── */}
|
||||||
|
<div className="group relative rounded-2xl border border-gray-200 bg-white p-6 flex flex-col shadow-sm hover:shadow-md hover:border-gray-300 hover:-translate-y-1 transition-all duration-300 cursor-default">
|
||||||
|
{/* Hover glow */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-b from-gray-50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<h3 className="text-lg font-bold text-gray-800 group-hover:text-gray-900 transition-colors">Free</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Get started</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<span className="text-4xl font-extrabold text-gray-900">$0</span>
|
||||||
|
<span className="text-sm text-gray-400 ml-1">/ month</span>
|
||||||
|
</div>
|
||||||
|
<ul className="relative space-y-2.5 flex-1 mb-6">
|
||||||
|
{[
|
||||||
|
"Browse public papers",
|
||||||
|
"View AI solutions",
|
||||||
|
"Basic error book",
|
||||||
|
].map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-start gap-2 text-sm text-gray-600 group-hover:text-gray-700 transition-colors"
|
||||||
|
style={{ transitionDelay: `${i * 30}ms` }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-300 group-hover:text-gray-400 shrink-0 mt-0.5 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="relative w-full py-2.5 rounded-xl text-sm font-semibold bg-gray-100 text-gray-400 cursor-default"
|
||||||
|
>
|
||||||
|
Free Forever
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Standard (current, with early bird) ── */}
|
||||||
|
<div className="group relative rounded-2xl border-2 border-indigo-500 bg-white p-6 flex flex-col shadow-lg shadow-indigo-100 scale-[1.03] hover:shadow-xl hover:shadow-indigo-200 hover:-translate-y-1 transition-all duration-300">
|
||||||
|
{/* Animated shimmer */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-indigo-50/80 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-in-out" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 text-[11px] font-bold px-4 py-1 rounded-full bg-indigo-600 text-white shadow-md whitespace-nowrap group-hover:shadow-lg group-hover:bg-indigo-700 transition-all duration-200">
|
||||||
|
Your Plan
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="relative mb-5 mt-1">
|
||||||
|
<h3 className="text-lg font-bold text-gray-800">Standard</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Most popular</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-1">
|
||||||
|
<span className="text-sm text-gray-400 line-through">$19.9</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-4xl font-extrabold text-indigo-600 group-hover:text-indigo-700 transition-colors">$9.9</span>
|
||||||
|
<span className="text-sm text-gray-400">/ month</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<span className="inline-block text-[10px] font-bold px-2.5 py-0.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-400 text-white tracking-wide group-hover:from-amber-500 group-hover:to-orange-500 group-hover:shadow-sm transition-all duration-200 animate-pulse">
|
||||||
|
EARLY BIRD PRICE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="relative space-y-2.5 flex-1 mb-6">
|
||||||
|
{[
|
||||||
|
"Unlimited paper uploads",
|
||||||
|
"AI question extraction",
|
||||||
|
"Step-by-step solutions",
|
||||||
|
"Photo auto-grading",
|
||||||
|
"Similar question retrieval",
|
||||||
|
"Course analytics",
|
||||||
|
"Error book + review",
|
||||||
|
].map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-start gap-2 text-sm text-gray-700 group-hover:translate-x-0.5 transition-transform duration-200"
|
||||||
|
style={{ transitionDelay: `${i * 30}ms` }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-indigo-500 group-hover:text-indigo-600 shrink-0 mt-0.5 transition-colors group-hover:scale-110 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="relative w-full py-2.5 rounded-xl text-sm font-bold bg-indigo-600 text-white cursor-default opacity-90"
|
||||||
|
>
|
||||||
|
Current Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Exam ── */}
|
||||||
|
<div className="group relative rounded-2xl border border-gray-200 bg-white p-6 flex flex-col shadow-sm hover:shadow-lg hover:border-amber-300 hover:-translate-y-1 transition-all duration-300 cursor-pointer">
|
||||||
|
{/* Hover glow */}
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-b from-amber-50/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||||
|
|
||||||
|
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 text-[11px] font-bold px-4 py-1 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-md whitespace-nowrap group-hover:shadow-lg group-hover:scale-105 transition-all duration-200">
|
||||||
|
Best Value
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="relative mb-5 mt-1">
|
||||||
|
<h3 className="text-lg font-bold text-gray-800 group-hover:text-gray-900 transition-colors">Exam</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Finals season</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative mb-5">
|
||||||
|
<span className="text-4xl font-extrabold text-gray-900 group-hover:text-amber-600 transition-colors duration-300">$29.9</span>
|
||||||
|
<span className="text-sm text-gray-400 ml-1">/ month</span>
|
||||||
|
</div>
|
||||||
|
<ul className="relative space-y-2.5 flex-1 mb-6">
|
||||||
|
{[
|
||||||
|
"Everything in Standard",
|
||||||
|
"AI Tutor (RAG-powered)",
|
||||||
|
"Priority processing",
|
||||||
|
"Unlimited variant generation",
|
||||||
|
"Cross-course analytics",
|
||||||
|
"Export & print",
|
||||||
|
].map((f, i) => (
|
||||||
|
<li
|
||||||
|
key={f}
|
||||||
|
className="flex items-start gap-2 text-sm text-gray-700 group-hover:translate-x-0.5 transition-transform duration-200"
|
||||||
|
style={{ transitionDelay: `${i * 30}ms` }}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-amber-500 group-hover:text-amber-600 shrink-0 mt-0.5 transition-all group-hover:scale-110" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button className="relative w-full py-2.5 rounded-xl text-sm font-bold bg-gray-900 text-white hover:bg-amber-600 hover:shadow-lg hover:shadow-amber-200 active:scale-[0.98] transition-all duration-200">
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-400 mt-7">
|
||||||
|
All registered users enjoy Early Bird pricing during beta.
|
||||||
|
Payment integration coming soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -141,10 +141,10 @@ export default function AnalyticsPage() {
|
|||||||
{analytics.high_yield_topics.length === 0 ? (
|
{analytics.high_yield_topics.length === 0 ? (
|
||||||
<div className="text-sm text-gray-400">No data yet.</div>
|
<div className="text-sm text-gray-400">No data yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-1">
|
||||||
{analytics.high_yield_topics.map((t, i) => (
|
{analytics.high_yield_topics.map((t, i) => (
|
||||||
<li key={t} className="flex items-center gap-3 text-sm text-gray-700">
|
<li key={t} className="flex items-center gap-3 text-sm text-gray-700 px-2 py-1.5 rounded-lg hover:bg-red-50/50 transition-colors duration-150 cursor-default">
|
||||||
<span className="w-6 h-6 rounded-full bg-red-50 text-red-600 flex items-center justify-center text-xs font-semibold">{i + 1}</span>
|
<span className="w-6 h-6 rounded-full bg-gradient-to-br from-red-400 to-rose-500 text-white flex items-center justify-center text-xs font-bold shadow-sm">{i + 1}</span>
|
||||||
<span>{t}</span>
|
<span>{t}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -328,57 +328,91 @@ function InteractiveChart({ topicData, typeData, diffData }: {
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-white border border-gray-200 rounded-2xl p-5">
|
<section className="bg-white border border-gray-200 rounded-2xl p-5 shadow-sm">
|
||||||
{/* Tab switcher */}
|
{/* Tab switcher */}
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
|
||||||
{(["topic", "type", "difficulty"] as const).map((t) => (
|
{(["topic", "type", "difficulty"] as const).map((t) => (
|
||||||
<button key={t} onClick={() => { setView(t); setHovered(null); }}
|
<button key={t} onClick={() => { setView(t); setHovered(null); }}
|
||||||
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
className={`text-xs px-3 py-1.5 rounded-md font-medium transition-all duration-200 ${
|
||||||
view === t ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-500 hover:text-gray-700"
|
view === t ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
|
||||||
}`}>
|
}`}>
|
||||||
{t === "topic" ? "Topics" : t === "type" ? "Types" : "Difficulty"}
|
{t === "topic" ? "Topics" : t === "type" ? "Types" : "Difficulty"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pie */}
|
{/* Pie — SVG donut */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-5">
|
||||||
<div className="relative w-36 h-36 shrink-0">
|
<div className="relative w-40 h-40 shrink-0">
|
||||||
<div
|
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90 drop-shadow-md">
|
||||||
className="w-full h-full rounded-full"
|
{segments.map((s, i) => {
|
||||||
style={{ background: `conic-gradient(${gradient})` }}
|
const r = 38;
|
||||||
/>
|
const circumference = 2 * Math.PI * r;
|
||||||
<div className="absolute inset-3 bg-white rounded-full flex items-center justify-center">
|
const dashLen = (s.pct / 100) * circumference;
|
||||||
{hovered !== null ? (
|
const dashOffset = -((s.start / 100) * circumference);
|
||||||
<div className="text-center">
|
const isHov = hovered === i;
|
||||||
<div className="text-lg font-bold text-gray-900">{segments[hovered].value}</div>
|
return (
|
||||||
<div className="text-[9px] text-gray-400">{segments[hovered].pct.toFixed(0)}%</div>
|
<circle
|
||||||
</div>
|
key={s.label}
|
||||||
) : (
|
cx="50" cy="50" r={r}
|
||||||
<div className="text-center">
|
fill="none"
|
||||||
<div className="text-lg font-bold text-gray-900">{total}</div>
|
stroke={s.color}
|
||||||
<div className="text-[9px] text-gray-400">total</div>
|
strokeWidth={isHov ? 14 : 11}
|
||||||
</div>
|
strokeDasharray={`${dashLen - 1} ${circumference - dashLen + 1}`}
|
||||||
)}
|
strokeDashoffset={dashOffset}
|
||||||
|
strokeLinecap="butt"
|
||||||
|
className="transition-all duration-300 cursor-pointer"
|
||||||
|
style={{ filter: isHov ? `drop-shadow(0 0 6px ${s.color}60)` : "none", opacity: hovered !== null && !isHov ? 0.5 : 1 }}
|
||||||
|
onMouseEnter={() => setHovered(i)}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{/* Center label */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center">
|
||||||
|
{hovered !== null ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xl font-bold text-gray-900 transition-all duration-200">{segments[hovered].value}</div>
|
||||||
|
<div className="text-[10px] text-gray-400 font-medium">{segments[hovered].pct.toFixed(1)}%</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xl font-bold text-gray-900">{total}</div>
|
||||||
|
<div className="text-[10px] text-gray-400 font-medium">total</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex-1 space-y-1 max-h-36 overflow-y-auto">
|
<div className="flex-1 space-y-0.5 max-h-40 overflow-y-auto">
|
||||||
{segments.map((s, i) => (
|
{segments.map((s, i) => {
|
||||||
<div
|
const isHov = hovered === i;
|
||||||
key={s.label}
|
return (
|
||||||
onMouseEnter={() => setHovered(i)}
|
<div
|
||||||
onMouseLeave={() => setHovered(null)}
|
key={s.label}
|
||||||
className={`flex items-center gap-2 px-2 py-1 rounded-lg cursor-default transition-colors ${
|
onMouseEnter={() => setHovered(i)}
|
||||||
hovered === i ? "bg-gray-50" : ""
|
onMouseLeave={() => setHovered(null)}
|
||||||
}`}
|
className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-lg cursor-default transition-all duration-200 ${
|
||||||
>
|
isHov ? "bg-gray-100 scale-[1.02]" : hovered !== null ? "opacity-50" : ""
|
||||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
|
}`}
|
||||||
<span className="text-xs text-gray-700 flex-1 truncate">{s.label}</span>
|
>
|
||||||
<span className="text-xs text-gray-400 tabular-nums">{s.value}</span>
|
<span
|
||||||
</div>
|
className="w-3 h-3 rounded shrink-0 transition-transform duration-200"
|
||||||
))}
|
style={{ backgroundColor: s.color, transform: isHov ? "scale(1.3)" : "scale(1)" }}
|
||||||
|
/>
|
||||||
|
<span className={`text-xs flex-1 truncate transition-colors duration-200 ${isHov ? "text-gray-900 font-medium" : "text-gray-700"}`}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs tabular-nums transition-colors duration-200 ${isHov ? "text-gray-900 font-semibold" : "text-gray-400"}`}>
|
||||||
|
{s.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -391,11 +425,14 @@ function QuestionCard({ question: q }: { question: QItem }) {
|
|||||||
const cleanPreview = (q.preview || "")
|
const cleanPreview = (q.preview || "")
|
||||||
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
|
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
|
||||||
.replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "")
|
.replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "")
|
||||||
|
.replace(/```\w*\n?/g, "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/paper/${q.paper_id}`}
|
<Link to={`/paper/${q.paper_id}`}
|
||||||
className="flex items-start gap-3 bg-gray-50 border border-gray-200 rounded-xl px-3.5 py-2.5 hover:border-blue-300 hover:bg-white hover:shadow-sm transition-all group">
|
className="flex items-start gap-3 bg-gray-50 border border-gray-200 border-l-2 border-l-transparent rounded-xl px-3.5 py-2.5 hover:border-blue-300 hover:border-l-blue-500 hover:bg-white hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 group">
|
||||||
<span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5">
|
<span className="shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-600 text-white text-xs font-bold mt-0.5">
|
||||||
{q.question_number}
|
{q.question_number}
|
||||||
</span>
|
</span>
|
||||||
@@ -437,18 +474,28 @@ function FilterRow({ label, children }: { label: string; children: React.ReactNo
|
|||||||
function Pill({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) {
|
function Pill({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick}
|
<button onClick={onClick}
|
||||||
className={`text-[10px] px-2 py-1 rounded-full border font-medium transition-colors whitespace-nowrap ${
|
className={`text-[10px] px-2 py-1 rounded-full border font-medium transition-all duration-150 whitespace-nowrap ${
|
||||||
active ? (color ?? "bg-blue-50 text-blue-700 border-blue-200") : "bg-white text-gray-400 border-gray-200 hover:text-gray-600"
|
active
|
||||||
|
? `${color ?? "bg-blue-50 text-blue-700 border-blue-200"} shadow-sm scale-105`
|
||||||
|
: "bg-white text-gray-400 border-gray-200 hover:text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:shadow-sm active:scale-95"
|
||||||
}`}>
|
}`}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KPI_ACCENTS: Record<string, { border: string; bg: string; text: string }> = {
|
||||||
|
Papers: { border: "border-l-blue-500", bg: "from-blue-50/60 to-white", text: "text-blue-600" },
|
||||||
|
Questions: { border: "border-l-violet-500", bg: "from-violet-50/60 to-white", text: "text-violet-600" },
|
||||||
|
Topics: { border: "border-l-emerald-500", bg: "from-emerald-50/60 to-white", text: "text-emerald-600" },
|
||||||
|
"Avg Difficulty": { border: "border-l-amber-500", bg: "from-amber-50/60 to-white", text: "text-amber-600" },
|
||||||
|
};
|
||||||
|
|
||||||
function KpiCard({ label, value }: { label: string; value: string | number }) {
|
function KpiCard({ label, value }: { label: string; value: string | number }) {
|
||||||
|
const accent = KPI_ACCENTS[label] ?? { border: "border-l-gray-400", bg: "from-gray-50/60 to-white", text: "text-gray-600" };
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-2xl p-5">
|
<div className={`bg-gradient-to-br ${accent.bg} border border-gray-200 border-l-4 ${accent.border} rounded-2xl p-5 hover:-translate-y-1 hover:shadow-md transition-all duration-200`}>
|
||||||
<div className="text-2xl font-semibold text-gray-900">{value}</div>
|
<div className={`text-3xl font-bold ${accent.text}`}>{value}</div>
|
||||||
<div className="text-xs uppercase tracking-wide text-gray-400 mt-2">{label}</div>
|
<div className="text-xs uppercase tracking-wide text-gray-400 mt-2">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -456,7 +503,7 @@ function KpiCard({ label, value }: { label: string; value: string | number }) {
|
|||||||
|
|
||||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-white border border-gray-200 rounded-2xl p-5">
|
<section className="bg-white border border-gray-200 rounded-2xl p-5 shadow-sm">
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">{title}</h2>
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
@@ -31,6 +31,38 @@ const DIFF_COLORS: Record<string, string> = {
|
|||||||
hard: "text-red-600",
|
hard: "text-red-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* --- Keyframe styles injected once --- */
|
||||||
|
const styleId = "error-book-animations";
|
||||||
|
if (typeof document !== "undefined" && !document.getElementById(styleId)) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes eb-pulse-badge {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.45); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(220,38,38,0); }
|
||||||
|
}
|
||||||
|
@keyframes eb-float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
@keyframes eb-count-up {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes eb-feedback-expand {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.eb-pulse-badge { animation: eb-pulse-badge 2s ease-in-out infinite; }
|
||||||
|
.eb-float { animation: eb-float 3s ease-in-out infinite; }
|
||||||
|
.eb-count-up { animation: eb-count-up 0.4s ease-out both; }
|
||||||
|
.eb-feedback-enter {
|
||||||
|
animation: eb-feedback-expand 0.3s ease-out both;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ErrorBookPage() {
|
export default function ErrorBookPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [entries, setEntries] = useState<UserAttempt[]>([]);
|
const [entries, setEntries] = useState<UserAttempt[]>([]);
|
||||||
@@ -111,7 +143,7 @@ export default function ErrorBookPage() {
|
|||||||
|
|
||||||
{!user && (
|
{!user && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||||
<div className="text-3xl mb-3">🔒</div>
|
<div className="text-3xl mb-3 eb-float">🔒</div>
|
||||||
<p className="text-gray-500 mb-4">Sign in to unlock your Error Book</p>
|
<p className="text-gray-500 mb-4">Sign in to unlock your Error Book</p>
|
||||||
<Link to="/login" className="inline-block px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors">
|
<Link to="/login" className="inline-block px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors">
|
||||||
Sign in
|
Sign in
|
||||||
@@ -123,7 +155,7 @@ export default function ErrorBookPage() {
|
|||||||
|
|
||||||
{user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && (
|
{user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||||
<div className="text-3xl mb-3">🎉</div>
|
<div className="text-3xl mb-3 eb-float">🎉</div>
|
||||||
<p className="text-gray-500">No mistakes yet. Keep practicing!</p>
|
<p className="text-gray-500">No mistakes yet. Keep practicing!</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -177,18 +209,28 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
|
|||||||
const typeColor = TYPE_COLORS[question.question_type] ?? "bg-gray-100 text-gray-600";
|
const typeColor = TYPE_COLORS[question.question_type] ?? "bg-gray-100 text-gray-600";
|
||||||
const diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? "";
|
const diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? "";
|
||||||
|
|
||||||
// Clean preview: strip boilerplate
|
// Clean preview: strip boilerplate and markdown code fences
|
||||||
const preview = (question.question_text || "")
|
const preview = (question.question_text || "")
|
||||||
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
|
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
|
||||||
.slice(0, 200);
|
.replace(/```\w*\n?/g, "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 250);
|
||||||
|
|
||||||
|
const isUnmastered = !entry.mastered;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
<article
|
||||||
|
className="bg-white border border-gray-200 rounded-xl overflow-hidden transition-all duration-300 ease-out hover:-translate-y-0.5 hover:shadow-lg border-l-[3px] border-l-red-400"
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-5 pt-4 pb-3">
|
<div className="px-5 pt-4 pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="inline-flex items-center justify-center w-9 h-9 rounded-lg bg-red-600 text-white text-sm font-bold">
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-10 h-10 rounded-lg bg-red-600 text-white text-sm font-bold shadow-md ${isUnmastered ? "eb-pulse-badge" : ""}`}
|
||||||
|
>
|
||||||
{question.question_number}
|
{question.question_number}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -240,13 +282,26 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
|
|||||||
<div className="border-t border-gray-100">
|
<div className="border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFeedback((v) => !v)}
|
onClick={() => setShowFeedback((v) => !v)}
|
||||||
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-medium text-blue-700 bg-blue-50/50 hover:bg-blue-50"
|
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-medium text-blue-700 bg-blue-50/50 hover:bg-blue-50 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<span>AI Feedback</span>
|
<span className="flex items-center gap-1.5">
|
||||||
<span>{showFeedback ? "▲" : "▼"}</span>
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5.002 5.002 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
AI Feedback
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="transition-transform duration-300"
|
||||||
|
style={{ display: "inline-block", transform: showFeedback ? "rotate(0deg)" : "rotate(180deg)" }}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{showFeedback && (
|
{showFeedback && (
|
||||||
<div className="px-5 py-4 bg-white">
|
<div
|
||||||
|
className="px-5 py-4 eb-feedback-enter"
|
||||||
|
style={{ background: "linear-gradient(180deg, rgba(239,246,255,0.5) 0%, rgba(255,255,255,1) 100%)" }}
|
||||||
|
>
|
||||||
<KaTeXRenderer html={entry.feedback} className="text-sm text-gray-700 leading-relaxed" />
|
<KaTeXRenderer html={entry.feedback} className="text-sm text-gray-700 leading-relaxed" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -256,14 +311,23 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-4 bg-gray-50/50">
|
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-4 bg-gray-50/50">
|
||||||
{paperId && (
|
{paperId && (
|
||||||
<Link to={`/paper/${paperId}`} className="text-xs font-medium text-blue-600 hover:text-blue-700">
|
<Link to={`/paper/${paperId}`} className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors duration-200">
|
||||||
Open paper →
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
Open paper
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<button onClick={onMastered} className="text-xs font-medium text-green-600 hover:text-green-700">
|
<button onClick={onMastered} className="inline-flex items-center gap-1 text-xs font-medium text-green-600 hover:text-green-800 transition-colors duration-200">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
Mark mastered
|
Mark mastered
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onRemove} className="text-xs font-medium text-gray-400 hover:text-gray-600">
|
<button onClick={onRemove} className="inline-flex items-center gap-1 text-xs font-medium text-gray-400 hover:text-red-500 transition-colors duration-200">
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,11 +336,18 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
const bg = color === "red" ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200";
|
const isRed = color === "red";
|
||||||
const text = color === "red" ? "text-red-700" : "text-blue-700";
|
|
||||||
return (
|
return (
|
||||||
<div className={`border rounded-xl px-4 py-2.5 ${bg}`}>
|
<div
|
||||||
<div className={`text-xl font-bold ${text}`}>{value}</div>
|
className="border rounded-xl px-4 py-2.5 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-md cursor-default"
|
||||||
|
style={{
|
||||||
|
background: isRed
|
||||||
|
? "linear-gradient(135deg, #fef2f2 0%, #fff1f2 100%)"
|
||||||
|
: "linear-gradient(135deg, #eff6ff 0%, #eef2ff 100%)",
|
||||||
|
borderColor: isRed ? "#fecaca" : "#bfdbfe",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`text-xl font-bold eb-count-up ${isRed ? "text-red-700" : "text-blue-700"}`}>{value}</div>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-gray-400 mt-0.5">{label}</div>
|
<div className="text-[10px] uppercase tracking-wide text-gray-400 mt-0.5">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,57 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { listPapers, myPapers } from "@/lib/api";
|
import { listPapers, myPapers } from "@/lib/api";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import PricingModal from "@/components/shared/PricingModal";
|
||||||
import type { Paper } from "@/types/api";
|
import type { Paper } from "@/types/api";
|
||||||
|
|
||||||
|
/* ── Count-up animation hook ── */
|
||||||
|
function useCountUp(target: number, duration = 1500, start = false): number {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!start || target === 0) { setValue(target); return; }
|
||||||
|
let startTime: number | null = null;
|
||||||
|
const step = (ts: number) => {
|
||||||
|
if (!startTime) startTime = ts;
|
||||||
|
const progress = Math.min((ts - startTime) / duration, 1);
|
||||||
|
// ease-out cubic
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
setValue(Math.round(eased * target));
|
||||||
|
if (progress < 1) rafRef.current = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(step);
|
||||||
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
|
}, [target, duration, start]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animated stat component ── */
|
||||||
|
function AnimatedStat({ target, label }: { target: number; label: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const value = useCountUp(target, 1500, visible);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect(); } },
|
||||||
|
{ threshold: 0.3 }
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-white" style={fontMono}>{value}</div>
|
||||||
|
<div className="text-xs text-indigo-300" style={fontSora}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getWorkedIds(userId: string): string[] {
|
function getWorkedIds(userId: string): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(`worked_papers_${userId}`);
|
const raw = localStorage.getItem(`worked_papers_${userId}`);
|
||||||
@@ -147,6 +196,7 @@ function Dropdown({
|
|||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
|
const [showPricing, setShowPricing] = useState(false);
|
||||||
const [papers, setPapers] = useState<Paper[]>([]);
|
const [papers, setPapers] = useState<Paper[]>([]);
|
||||||
const [papersLoading, setPapersLoading] = useState(false);
|
const [papersLoading, setPapersLoading] = useState(false);
|
||||||
const [myUploadedPapers, setMyUploadedPapers] = useState<Paper[]>([]);
|
const [myUploadedPapers, setMyUploadedPapers] = useState<Paper[]>([]);
|
||||||
@@ -159,9 +209,12 @@ export default function HomePage() {
|
|||||||
const [analyzing, setAnalyzing] = useState(false);
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
const inputRef = useRef<HTMLDivElement>(null);
|
const inputRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Derive course options dynamically from loaded papers
|
||||||
|
const courseOptions = Array.from(new Set(papers.map((p) => p.course_code))).sort();
|
||||||
|
|
||||||
// Autocomplete suggestions
|
// Autocomplete suggestions
|
||||||
const suggestions = courseInput.trim()
|
const suggestions = courseInput.trim()
|
||||||
? COURSE_OPTIONS.filter((c) =>
|
? courseOptions.filter((c) =>
|
||||||
c.toLowerCase().includes(courseInput.trim().toLowerCase())
|
c.toLowerCase().includes(courseInput.trim().toLowerCase())
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
@@ -236,16 +289,63 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen" style={{ background: "#FAFAFA" }}>
|
<div className="min-h-screen" style={{ background: "#FAFAFA" }}>
|
||||||
|
{/* Injected animation styles */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
.hero-gradient-text {
|
||||||
|
background: linear-gradient(135deg, #A5B4FC, #6EE7B7, #A5B4FC, #818CF8);
|
||||||
|
background-size: 300% 300%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
animation: gradientShift 6s ease infinite;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.feature-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(99,102,241,0.04), rgba(14,165,233,0.04));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(99,102,241,0.10), 0 2px 8px rgba(0,0,0,0.06);
|
||||||
|
border-color: #A5B4FC;
|
||||||
|
}
|
||||||
|
.feature-card:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.feature-card:hover .feature-icon-bg {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
.feature-icon-bg {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.paper-card-hover {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.paper-card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99,102,241,0.08);
|
||||||
|
border-left-color: #6366F1;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
{/* ══════ Nav ══════ */}
|
{/* ══════ Nav ══════ */}
|
||||||
<nav className="bg-white border-b border-slate-200">
|
<nav className="bg-white border-b border-slate-200">
|
||||||
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center justify-between">
|
<div className="px-6 h-14 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<img src="/favicon.jpg" alt="KnowIt" className="w-8 h-8 object-contain" />
|
||||||
className="w-8 h-8 flex items-center justify-center text-white text-sm font-bold"
|
|
||||||
style={{ background: "#6366F1", borderRadius: 0 }}
|
|
||||||
>
|
|
||||||
PM
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-slate-800" style={fontSora}>
|
<span className="text-lg font-bold text-slate-800" style={fontSora}>
|
||||||
PastPaper Master
|
PastPaper Master
|
||||||
</span>
|
</span>
|
||||||
@@ -270,6 +370,15 @@ export default function HomePage() {
|
|||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3 pl-3 border-l border-slate-200">
|
<div className="flex items-center gap-3 pl-3 border-l border-slate-200">
|
||||||
<span className="text-xs text-slate-400 max-w-[140px] truncate" style={fontMono}>{user.email}</span>
|
<span className="text-xs text-slate-400 max-w-[140px] truncate" style={fontMono}>{user.email}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPricing(true)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-semibold px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200 hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||||
|
</svg>
|
||||||
|
Standard
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => void signOut()}
|
onClick={() => void signOut()}
|
||||||
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
|
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
|
||||||
@@ -300,7 +409,7 @@ export default function HomePage() {
|
|||||||
style={fontSora}
|
style={fontSora}
|
||||||
>
|
>
|
||||||
The Smartest Way to<br />
|
The Smartest Way to<br />
|
||||||
<span style={{ color: "#A5B4FC" }}>Master Past Papers</span>
|
<span className="hero-gradient-text">Master Past Papers</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-indigo-200 text-base mb-10 max-w-xl mx-auto" style={fontSora}>
|
<p className="text-indigo-200 text-base mb-10 max-w-xl mx-auto" style={fontSora}>
|
||||||
Upload any HKUST past paper. AI breaks down every question with analysis,
|
Upload any HKUST past paper. AI breaks down every question with analysis,
|
||||||
@@ -322,7 +431,7 @@ export default function HomePage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value.toUpperCase();
|
const v = e.target.value.toUpperCase();
|
||||||
setCourseInput(v);
|
setCourseInput(v);
|
||||||
setCourseFilter(COURSE_OPTIONS.includes(v) ? v : null);
|
setCourseFilter(courseOptions.includes(v) ? v : null);
|
||||||
setShowSuggestions(true);
|
setShowSuggestions(true);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setShowSuggestions(true)}
|
onFocus={() => setShowSuggestions(true)}
|
||||||
@@ -513,18 +622,11 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick stats — real data */}
|
{/* Quick stats — real data with count-up animation */}
|
||||||
<div className="flex justify-center gap-8 mt-10">
|
<div className="flex justify-center gap-8 mt-10">
|
||||||
{[
|
<AnimatedStat target={papers.filter(p => p.status === "ready").length} label="Past Papers" />
|
||||||
[String(papers.filter(p => p.status === "ready").length), "Past Papers"],
|
<AnimatedStat target={papers.reduce((s, p) => s + (p.question_count || 0), 0)} label="Questions Analyzed" />
|
||||||
[String(papers.reduce((s, p) => s + (p.question_count || 0), 0)), "Questions Analyzed"],
|
<AnimatedStat target={new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size} label="Courses" />
|
||||||
[String(new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size), "Courses"],
|
|
||||||
].map(([num, label]) => (
|
|
||||||
<div key={label} className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-white" style={fontMono}>{num}</div>
|
|
||||||
<div className="text-xs text-indigo-300" style={fontSora}>{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -551,11 +653,11 @@ export default function HomePage() {
|
|||||||
{FEATURES.map((f) => (
|
{FEATURES.map((f) => (
|
||||||
<div
|
<div
|
||||||
key={f.title}
|
key={f.title}
|
||||||
className="bg-white border border-slate-200 p-5 hover:border-slate-300 transition-colors group"
|
className="feature-card bg-white border border-slate-200 p-5 group"
|
||||||
style={{ borderRadius: 0 }}
|
style={{ borderRadius: 0 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 flex items-center justify-center text-white mb-4"
|
className="feature-icon-bg w-10 h-10 flex items-center justify-center text-white mb-4"
|
||||||
style={{ background: f.color, borderRadius: 0 }}
|
style={{ background: f.color, borderRadius: 0 }}
|
||||||
>
|
>
|
||||||
{f.icon}
|
{f.icon}
|
||||||
@@ -597,7 +699,7 @@ export default function HomePage() {
|
|||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to={p.status === "ready" ? `/paper/${p.id}` : "#"}
|
to={p.status === "ready" ? `/paper/${p.id}` : "#"}
|
||||||
className="flex items-center justify-between bg-white border border-slate-200 px-4 py-3 hover:border-indigo-300 transition-colors"
|
className="paper-card-hover flex items-center justify-between bg-white border border-slate-200 px-4 py-3"
|
||||||
style={{ borderRadius: 0 }}
|
style={{ borderRadius: 0 }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -633,7 +735,7 @@ export default function HomePage() {
|
|||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to={`/paper/${p.id}`}
|
to={`/paper/${p.id}`}
|
||||||
className="flex items-center justify-between bg-white border border-slate-200 px-4 py-3 hover:border-indigo-300 transition-colors"
|
className="paper-card-hover flex items-center justify-between bg-white border border-slate-200 px-4 py-3"
|
||||||
style={{ borderRadius: 0 }}
|
style={{ borderRadius: 0 }}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -700,6 +802,8 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{showPricing && <PricingModal onClose={() => setShowPricing(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user