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:
Zhao
2026-04-21 18:40:22 +07:00
parent 7a09167261
commit 10fa2b74ef
7 changed files with 580 additions and 91 deletions

View File

@@ -144,6 +144,7 @@ Solution requirements:
- Mark common mistakes with <div class="common-error">...</div>
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
- Inline formula: $x^2$ no line break
- Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}}
@@ -174,6 +175,7 @@ Rules:
- Keep each item matched to the same question_number
- All text must be in English
- 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 long questions, show a complete derivation or reasoning chain
- Use <ol> or numbered steps in solution when appropriate

View 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())

View File

@@ -1,5 +1,7 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import PricingModal from "@/components/shared/PricingModal";
export default function Header({
courseCode,
@@ -9,8 +11,10 @@ export default function Header({
paperTitle?: string;
}) {
const { user, signOut } = useAuth();
const [showPricing, setShowPricing] = useState(false);
return (
<>
<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">
PastPaper Master
@@ -48,6 +52,15 @@ export default function Header({
{user ? (
<div className="flex items-center gap-3 pl-4 border-l border-gray-200">
<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
onClick={signOut}
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>
</header>
{showPricing && <PricingModal onClose={() => setShowPricing(false)} />}
</>
);
}

View 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"
>
&times;
</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>
);
}

View File

@@ -141,10 +141,10 @@ export default function AnalyticsPage() {
{analytics.high_yield_topics.length === 0 ? (
<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) => (
<li key={t} className="flex items-center gap-3 text-sm text-gray-700">
<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>
<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-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>
</li>
))}
@@ -328,57 +328,91 @@ function InteractiveChart({ topicData, typeData, diffData }: {
.join(", ");
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 */}
<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) => (
<button key={t} onClick={() => { setView(t); setHovered(null); }}
className={`text-xs px-3 py-1.5 rounded-lg font-medium transition-colors ${
view === t ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-500 hover:text-gray-700"
className={`text-xs px-3 py-1.5 rounded-md font-medium transition-all duration-200 ${
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"}
</button>
))}
</div>
{/* Pie */}
<div className="flex items-center gap-4">
<div className="relative w-36 h-36 shrink-0">
<div
className="w-full h-full rounded-full"
style={{ background: `conic-gradient(${gradient})` }}
/>
<div className="absolute inset-3 bg-white rounded-full flex items-center justify-center">
{hovered !== null ? (
<div className="text-center">
<div className="text-lg font-bold text-gray-900">{segments[hovered].value}</div>
<div className="text-[9px] text-gray-400">{segments[hovered].pct.toFixed(0)}%</div>
</div>
) : (
<div className="text-center">
<div className="text-lg font-bold text-gray-900">{total}</div>
<div className="text-[9px] text-gray-400">total</div>
</div>
)}
{/* Pie — SVG donut */}
<div className="flex items-center gap-5">
<div className="relative w-40 h-40 shrink-0">
<svg viewBox="0 0 100 100" className="w-full h-full -rotate-90 drop-shadow-md">
{segments.map((s, i) => {
const r = 38;
const circumference = 2 * Math.PI * r;
const dashLen = (s.pct / 100) * circumference;
const dashOffset = -((s.start / 100) * circumference);
const isHov = hovered === i;
return (
<circle
key={s.label}
cx="50" cy="50" r={r}
fill="none"
stroke={s.color}
strokeWidth={isHov ? 14 : 11}
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>
{/* Legend */}
<div className="flex-1 space-y-1 max-h-36 overflow-y-auto">
{segments.map((s, i) => (
<div
key={s.label}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
className={`flex items-center gap-2 px-2 py-1 rounded-lg cursor-default transition-colors ${
hovered === i ? "bg-gray-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>
</div>
))}
<div className="flex-1 space-y-0.5 max-h-40 overflow-y-auto">
{segments.map((s, i) => {
const isHov = hovered === i;
return (
<div
key={s.label}
onMouseEnter={() => setHovered(i)}
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-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>
</section>
@@ -391,11 +425,14 @@ function QuestionCard({ question: q }: { question: QItem }) {
const cleanPreview = (q.preview || "")
.replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "")
.replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "")
.replace(/```\w*\n?/g, "")
.replace(/<[^>]*>/g, "")
.replace(/\s+/g, " ")
.trim();
return (
<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">
{q.question_number}
</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 }) {
return (
<button onClick={onClick}
className={`text-[10px] px-2 py-1 rounded-full border font-medium transition-colors 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"
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"} 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}
</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 }) {
const accent = KPI_ACCENTS[label] ?? { border: "border-l-gray-400", bg: "from-gray-50/60 to-white", text: "text-gray-600" };
return (
<div className="bg-white border border-gray-200 rounded-2xl p-5">
<div className="text-2xl font-semibold text-gray-900">{value}</div>
<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-3xl font-bold ${accent.text}`}>{value}</div>
<div className="text-xs uppercase tracking-wide text-gray-400 mt-2">{label}</div>
</div>
);
@@ -456,7 +503,7 @@ function KpiCard({ label, value }: { label: string; value: string | number }) {
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
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>
{children}
</section>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { Link } from "react-router-dom";
import Header from "@/components/layout/Header";
@@ -31,6 +31,38 @@ const DIFF_COLORS: Record<string, string> = {
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() {
const { user } = useAuth();
const [entries, setEntries] = useState<UserAttempt[]>([]);
@@ -111,7 +143,7 @@ export default function ErrorBookPage() {
{!user && (
<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>
<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
@@ -123,7 +155,7 @@ export default function ErrorBookPage() {
{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="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>
</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 diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? "";
// Clean preview: strip boilerplate
// Clean preview: strip boilerplate and markdown code fences
const preview = (question.question_text || "")
.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 (
<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 */}
<div className="px-5 pt-4 pb-3">
<div className="flex items-start justify-between gap-3">
<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}
</span>
<div>
@@ -240,13 +282,26 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
<div className="border-t border-gray-100">
<button
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>{showFeedback ? "▲" : "▼"}</span>
<span className="flex items-center gap-1.5">
<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>
{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" />
</div>
)}
@@ -256,14 +311,23 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
{/* Actions */}
<div className="border-t border-gray-100 px-5 py-2.5 flex items-center gap-4 bg-gray-50/50">
{paperId && (
<Link to={`/paper/${paperId}`} className="text-xs font-medium text-blue-600 hover:text-blue-700">
Open paper
<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">
<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>
)}
<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
</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
</button>
</div>
@@ -272,11 +336,18 @@ function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMast
}
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 text = color === "red" ? "text-red-700" : "text-blue-700";
const isRed = color === "red";
return (
<div className={`border rounded-xl px-4 py-2.5 ${bg}`}>
<div className={`text-xl font-bold ${text}`}>{value}</div>
<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>
);

View File

@@ -2,8 +2,57 @@ import { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { listPapers, myPapers } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
import PricingModal from "@/components/shared/PricingModal";
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[] {
try {
const raw = localStorage.getItem(`worked_papers_${userId}`);
@@ -147,6 +196,7 @@ function Dropdown({
export default function HomePage() {
const navigate = useNavigate();
const { user, signOut } = useAuth();
const [showPricing, setShowPricing] = useState(false);
const [papers, setPapers] = useState<Paper[]>([]);
const [papersLoading, setPapersLoading] = useState(false);
const [myUploadedPapers, setMyUploadedPapers] = useState<Paper[]>([]);
@@ -159,9 +209,12 @@ export default function HomePage() {
const [analyzing, setAnalyzing] = useState(false);
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
const suggestions = courseInput.trim()
? COURSE_OPTIONS.filter((c) =>
? courseOptions.filter((c) =>
c.toLowerCase().includes(courseInput.trim().toLowerCase())
)
: [];
@@ -236,16 +289,63 @@ export default function HomePage() {
return (
<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 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="w-8 h-8 flex items-center justify-center text-white text-sm font-bold"
style={{ background: "#6366F1", borderRadius: 0 }}
>
PM
</div>
<img src="/favicon.jpg" alt="KnowIt" className="w-8 h-8 object-contain" />
<span className="text-lg font-bold text-slate-800" style={fontSora}>
PastPaper Master
</span>
@@ -270,6 +370,15 @@ export default function HomePage() {
{user ? (
<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>
<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
onClick={() => void signOut()}
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
@@ -300,7 +409,7 @@ export default function HomePage() {
style={fontSora}
>
The Smartest Way to<br />
<span style={{ color: "#A5B4FC" }}>Master Past Papers</span>
<span className="hero-gradient-text">Master Past Papers</span>
</h1>
<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,
@@ -322,7 +431,7 @@ export default function HomePage() {
onChange={(e) => {
const v = e.target.value.toUpperCase();
setCourseInput(v);
setCourseFilter(COURSE_OPTIONS.includes(v) ? v : null);
setCourseFilter(courseOptions.includes(v) ? v : null);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
@@ -513,18 +622,11 @@ export default function HomePage() {
)}
</div>
{/* Quick stats — real data */}
{/* Quick stats — real data with count-up animation */}
<div className="flex justify-center gap-8 mt-10">
{[
[String(papers.filter(p => p.status === "ready").length), "Past Papers"],
[String(papers.reduce((s, p) => s + (p.question_count || 0), 0)), "Questions Analyzed"],
[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>
))}
<AnimatedStat target={papers.filter(p => p.status === "ready").length} label="Past Papers" />
<AnimatedStat target={papers.reduce((s, p) => s + (p.question_count || 0), 0)} label="Questions Analyzed" />
<AnimatedStat target={new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size} label="Courses" />
</div>
</div>
@@ -551,11 +653,11 @@ export default function HomePage() {
{FEATURES.map((f) => (
<div
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 }}
>
<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 }}
>
{f.icon}
@@ -597,7 +699,7 @@ export default function HomePage() {
<Link
key={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 }}
>
<div>
@@ -633,7 +735,7 @@ export default function HomePage() {
<Link
key={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 }}
>
<div>
@@ -700,6 +802,8 @@ export default function HomePage() {
</div>
</div>
</footer>
{showPricing && <PricingModal onClose={() => setShowPricing(false)} />}
</div>
);
}