New: Ui Redesign (#1)
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s

Reviewed-on: #1
This commit is contained in:
Elijah 2026-07-11 13:50:31 -07:00
parent 009d1eb065
commit bd071b3906
22 changed files with 464 additions and 524 deletions

BIN
dev.db

Binary file not shown.

View file

@ -90,9 +90,9 @@ export default function DeckStudyPage() {
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
{/* Header */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row md:items-center">
<div>
<button
onClick={() => router.push(`/${classSlug}/flashcards`)}
@ -103,7 +103,8 @@ export default function DeckStudyPage() {
</svg>
Back to decks
</button>
<h1 className="text-xl font-bold text-text-heading">{deck.name}</h1>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Flashcard session</p>
<h1 className="editorial-title mt-1 text-3xl text-text-heading">{deck.name}</h1>
{deck.description && (
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
)}
@ -126,7 +127,7 @@ export default function DeckStudyPage() {
<div className="flex items-center gap-2 md:gap-4 ml-auto">
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
<button
onClick={() => setView("study")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${

View file

@ -76,8 +76,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
const progressLabel = getProgressLabel(deck);
return (
<div ref={setNodeRef} style={style} className="group flex flex-col bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300 h-full">
<div className="p-4 flex flex-col flex-1 h-full">
<div ref={setNodeRef} style={style} className="group flex h-full flex-col rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
<div className="flex h-full flex-1 flex-col p-5">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -85,7 +85,7 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{deck.name}</h3>
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{deck.name}</h3>
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
</div>
</div>
@ -369,15 +369,15 @@ export default function FlashcardsPage() {
const activeDeck = decks.find(d => d.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
<div className="grid grid-cols-2 gap-3 sm:flex">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -387,7 +387,7 @@ export default function FlashcardsPage() {
</div>
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
<input
autoFocus
type="text"
@ -407,8 +407,8 @@ export default function FlashcardsPage() {
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit deck</h3>
<input
type="text"
value={editName}
@ -443,7 +443,7 @@ export default function FlashcardsPage() {
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedDecks.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
@ -456,7 +456,7 @@ export default function FlashcardsPage() {
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
</div>
)}
@ -484,7 +484,7 @@ export default function FlashcardsPage() {
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>

View file

@ -11,7 +11,7 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
}
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="app-page">
{/* Dynamic Header & Tabs */}
<ClassHeader classSlug={classSlug} className={classData.name} />

View file

@ -124,9 +124,9 @@ export default function QuizStudyPage() {
}
return (
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
<div className="mx-auto w-full max-w-5xl py-1 md:py-3">
{/* Header */}
<div className="flex flex-col md:flex-row items-start justify-between gap-4 mb-6">
<div className="mb-7 flex flex-col items-start justify-between gap-4 rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5 md:flex-row">
<div>
<button
onClick={() => router.push(`/${classSlug}/quizzes`)}
@ -142,7 +142,7 @@ export default function QuizStudyPage() {
onClick={() => setShowTopics(!showTopics)}
className="flex items-center gap-2 group cursor-pointer text-left focus:outline-none mb-2"
>
<h1 className="text-3xl font-bold font-serif text-text-heading group-hover:text-primary transition-colors">
<h1 className="editorial-title text-3xl text-text-heading transition-colors group-hover:text-primary sm:text-4xl">
{quiz.name}
</h1>
<svg
@ -180,7 +180,7 @@ export default function QuizStudyPage() {
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
{attempts.length > 0 && (
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
<div className="flex rounded-xl border border-border-light bg-bg-surface-alt p-1">
<button
onClick={() => setView("history")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${

View file

@ -54,8 +54,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
};
return (
<div ref={setNodeRef} style={style} className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300">
<div className="p-4 flex flex-col flex-1 h-full">
<div ref={setNodeRef} style={style} className="group rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-[var(--shadow-card-hover)]">
<div className="flex h-full flex-1 flex-col p-5">
<div className="flex items-start gap-2">
<div {...attributes} {...listeners} className="mt-1 cursor-grab active:cursor-grabbing text-text-muted hover:text-text-heading">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -63,7 +63,7 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
</svg>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-heading mb-1">{quiz.name}</h3>
<h3 className="mb-1 text-lg font-extrabold text-text-heading">{quiz.name}</h3>
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
</div>
</div>
@ -343,15 +343,15 @@ export default function QuizzesPage() {
const activeQuiz = quizzes.find(q => q.id === activeId);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<div className="pb-20">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
<div className="flex items-center gap-3">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-bg-surface-alt text-text-heading font-medium border border-border-light hover:bg-border transition-all duration-200 shadow-sm cursor-pointer">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
<div className="grid grid-cols-2 gap-3 sm:flex">
<button onClick={() => setIsCreatingGroup(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border-light bg-bg-surface px-4 font-bold text-text-heading shadow-sm transition-colors hover:bg-bg-surface-alt">
Add Group
</button>
<button onClick={() => setShowImport(true)} className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer">
<button onClick={() => setShowImport(true)} className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-white shadow-sm transition-colors hover:bg-primary-hover">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
@ -361,7 +361,7 @@ export default function QuizzesPage() {
</div>
{isCreatingGroup && (
<div className="mb-6 p-4 bg-bg-surface rounded-xl border border-primary/20 shadow-sm flex items-center gap-3">
<div className="animate-slide-up mb-6 flex flex-col gap-3 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-sm sm:flex-row sm:items-center">
<input
autoFocus
type="text"
@ -382,8 +382,8 @@ export default function QuizzesPage() {
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
{editingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-bg-surface rounded-xl p-6 w-full max-w-md shadow-lg border border-border">
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit quiz</h3>
<input
type="text"
value={editName}
@ -418,7 +418,7 @@ export default function QuizzesPage() {
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
<div className="space-y-8">
{groupedQuizzes.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? (
<div className="flex items-center gap-2">
@ -431,7 +431,7 @@ export default function QuizzesPage() {
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
<h3 className="text-lg font-extrabold text-text-heading">{group.name}</h3>
</div>
)}
@ -459,7 +459,7 @@ export default function QuizzesPage() {
))}
{/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
<div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>

View file

@ -13,9 +13,9 @@ export default async function ProtectedLayout({
}
return (
<div className="min-h-screen flex flex-col">
<div className="min-h-screen">
<Navbar />
<main className="flex-1">{children}</main>
<main className="min-h-screen md:ml-60">{children}</main>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
interface ClassItem {
@ -10,6 +10,8 @@ interface ClassItem {
_count: { decks: number; quizSets: number };
}
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(true);
@ -20,256 +22,128 @@ export default function HomePage() {
const [editName, setEditName] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
useEffect(() => {
fetchClasses();
}, []);
useEffect(() => { fetchClasses(); }, []);
async function fetchClasses() {
try {
const res = await fetch("/api/classes");
const data = await res.json();
setClasses(data);
} finally {
setLoading(false);
}
setClasses(await res.json());
} finally { setLoading(false); }
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim()) return;
setCreating(true);
try {
const res = await fetch("/api/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
if (res.ok) {
setNewName("");
setShowCreate(false);
fetchClasses();
}
} finally {
setCreating(false);
}
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
} finally { setCreating(false); }
}
async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
await fetch(`/api/classes/${id}`, { method: "DELETE" });
fetchClasses();
}
async function handleSaveEdit(id: string) {
if (!editName.trim()) {
setEditingId(null);
return;
}
if (!editName.trim()) { setEditingId(null); return; }
setSavingEdit(true);
try {
const res = await fetch(`/api/classes/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim() }),
});
if (res.ok) {
setEditingId(null);
fetchClasses();
}
} finally {
setSavingEdit(false);
}
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
if (res.ok) { setEditingId(null); fetchClasses(); }
} finally { setSavingEdit(false); }
}
const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0);
const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0);
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
<p className="text-text-secondary mt-1">
Select a class to study flashcards or take quizzes
</p>
<div className="app-page">
<section className="relative mb-10 overflow-hidden rounded-[2rem] bg-[#172033] px-6 py-8 text-white shadow-[var(--shadow-card)] sm:px-9 sm:py-10 dark:bg-[#111827]">
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/70 blur-3xl" />
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/35 blur-3xl" />
<div className="relative flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="mb-3 text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
<h1 className="editorial-title max-w-2xl text-4xl leading-[1.02] sm:text-5xl">What are we learning today?</h1>
<p className="mt-4 max-w-xl text-sm leading-6 text-white/65 sm:text-base">Pick up where you left off, sharpen a weak topic, or build something new.</p>
</div>
<button onClick={() => setShowCreate(true)} className="inline-flex min-h-12 items-center justify-center gap-2 self-start rounded-xl bg-white px-5 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 lg:self-auto">
<span className="text-xl leading-none">+</span> New class
</button>
</div>
<button
onClick={() => setShowCreate(!showCreate)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Class
</button>
</div>
{/* Create class inline form */}
{showCreate && (
<div className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] p-5 mb-6 animate-slide-up">
<form onSubmit={handleCreate} className="flex gap-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. PHA 109, Anatomy, Nursing Fundamentals..."
autoFocus
className="flex-1 px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
<button
type="submit"
disabled={creating || !newName.trim()}
className="px-5 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
>
{creating ? "Creating..." : "Create"}
</button>
<button
type="button"
onClick={() => {
setShowCreate(false);
setNewName("");
}}
className="px-4 py-2.5 rounded-lg border border-border text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
Cancel
</button>
</form>
</div>
)}
{/* Loading state */}
{loading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse"
>
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
<div className="relative mt-8 grid max-w-xl grid-cols-3 gap-3 border-t border-white/12 pt-6">
{[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => (
<div key={label}>
<div className="text-2xl font-extrabold sm:text-3xl">{value}</div>
<div className="mt-1 text-xs font-semibold text-white/50">{label}</div>
</div>
))}
</div>
</section>
{showCreate && (
<section className="animate-slide-up mb-8 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-[var(--shadow-card)] sm:p-6">
<div className="mb-4">
<h2 className="editorial-title text-2xl text-text-heading">Create a class</h2>
<p className="mt-1 text-sm text-text-secondary">Give this study space a short, memorable name.</p>
</div>
<form onSubmit={handleCreate} className="flex flex-col gap-3 sm:flex-row">
<input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="e.g. Pathophysiology" autoFocus className="min-h-12 flex-1 rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" />
<button type="submit" disabled={creating || !newName.trim()} className="min-h-12 rounded-xl bg-primary px-6 text-sm font-bold text-white hover:bg-primary-hover disabled:opacity-45">{creating ? "Creating…" : "Create class"}</button>
<button type="button" onClick={() => { setShowCreate(false); setNewName(""); }} className="min-h-12 rounded-xl border border-border px-5 text-sm font-bold text-text-secondary hover:bg-bg-surface-alt">Cancel</button>
</form>
</section>
)}
{/* Empty state */}
{!loading && classes.length === 0 && (
<div className="text-center py-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<h2 className="text-lg font-semibold text-text-heading mb-1">
No classes yet
</h2>
<p className="text-text-secondary mb-4">
Create your first class to start studying
</p>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Your First Class
</button>
<div className="mb-5 flex items-end justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Study spaces</p>
<h2 className="editorial-title mt-1 text-3xl text-text-heading">Your classes</h2>
</div>
<p className="hidden text-sm text-text-muted sm:block">Flashcards and quizzes, organized by class.</p>
</div>
{loading && (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[1, 2, 3].map((i) => <div key={i} className="h-52 animate-subtle-pulse rounded-2xl border border-border-light bg-bg-surface" />)}
</div>
)}
{!loading && classes.length === 0 && (
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-text-secondary">Create your first class, then add flashcards or practice quizzes.</p>
<button onClick={() => setShowCreate(true)} className="mt-6 min-h-11 rounded-xl bg-primary px-5 text-sm font-bold text-white hover:bg-primary-hover">Create your first class</button>
</div>
)}
{/* Class grid */}
{!loading && classes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classes.map((cls) => (
<div
key={cls.id}
className="group relative bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
>
<Link
href={`/${cls.slug}`}
className="block p-6"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{classes.map((cls, index) => (
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">
<div className="h-1.5" style={{ backgroundColor: accents[index % accents.length] }} />
<Link href={`/${cls.slug}`} className="block min-h-48 p-5 sm:p-6">
<div className="mb-8 flex items-start justify-between gap-4">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-bg-surface-alt text-sm font-extrabold text-text-heading">{cls.name.slice(0, 2).toUpperCase()}</div>
<span className="text-xl text-text-muted transition-transform group-hover:translate-x-1" aria-hidden></span>
</div>
{editingId === cls.id ? (
<input
type="text"
value={editName}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleSaveEdit(cls.id);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setEditingId(null);
}
}}
onBlur={() => handleSaveEdit(cls.id)}
autoFocus
disabled={savingEdit}
className="w-[calc(100%-2.5rem)] text-lg font-semibold text-text-heading px-2 py-1 -ml-2 rounded bg-bg-surface-alt border border-primary/30 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
) : (
<h3 className="text-lg font-semibold text-text-heading group-hover:text-primary transition-colors">
{cls.name}
</h3>
)}
<div className="flex gap-4 mt-3">
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-text-secondary">
<svg className="w-4 h-4 text-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}
</span>
<input value={editName} onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} onChange={(e) => setEditName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSaveEdit(cls.id); } else if (e.key === "Escape") setEditingId(null); }} onBlur={() => handleSaveEdit(cls.id)} autoFocus disabled={savingEdit} className="w-full rounded-lg border border-primary bg-bg-surface-alt px-2 py-1 text-lg font-bold text-text-heading" />
) : <h3 className="text-xl font-extrabold text-text-heading group-hover:text-primary">{cls.name}</h3>}
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
</div>
</Link>
{/* Actions */}
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
{/* Delete button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(cls.id, cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-colors cursor-pointer"
title="Delete class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
{/* Edit button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditingId(cls.id);
setEditName(cls.name);
}}
className="p-1.5 rounded-lg text-text-muted hover:text-primary hover:bg-primary/10 transition-colors cursor-pointer"
title="Rename class"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<div className="absolute right-3 top-4 flex gap-1 rounded-xl border border-border-light bg-bg-surface p-1 opacity-100 shadow-sm sm:opacity-0 sm:group-hover:opacity-100">
<button onClick={() => { setEditingId(cls.id); setEditName(cls.name); }} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-bg-callout hover:text-primary" title="Rename class" aria-label={`Rename ${cls.name}`}></button>
<button onClick={() => handleDelete(cls.id, cls.name)} className="grid h-9 w-9 place-items-center rounded-lg text-text-muted hover:bg-error-bg hover:text-error" title="Delete class" aria-label={`Delete ${cls.name}`}>×</button>
</div>
</div>
</article>
))}
</div>
)}

View file

@ -2,64 +2,104 @@
@layer base {
:root {
--theme-bg-base: #f0f4f2;
--theme-bg-surface: #ffffff;
--theme-bg-surface-alt: #f4f7f5;
--theme-bg-callout: #e8f4f0;
--theme-primary: #2a7d7d;
--theme-primary-hover: #1f6363;
--theme-primary-light: #3a9e9e;
--theme-text-heading: #1a2f2f;
--theme-text-body: #2d4a4a;
--theme-text-secondary: #4a6a6a;
--theme-text-muted: #7a9a9a;
--theme-border: #c8d8d0;
--theme-border-light: #dce8e2;
--theme-success: #2d8a4e;
--theme-success-bg: #e8f5ed;
--theme-error: #c44c4c;
--theme-error-bg: #fce8e8;
--theme-badge-bg: #e0eeea;
--theme-badge-text: #2a6a5a;
--theme-danger: #dc3545;
--theme-danger-hover: #c82333;
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
--theme-bg-base: #f5f2ea;
--theme-bg-surface: #fffdf8;
--theme-bg-surface-alt: #eeeae0;
--theme-bg-callout: #ebe9ff;
--theme-primary: #4f46e5;
--theme-primary-hover: #3f37c9;
--theme-primary-light: #756ef0;
--theme-accent: #f97360;
--theme-text-heading: #172033;
--theme-text-body: #344054;
--theme-text-secondary: #667085;
--theme-text-muted: #8b91a0;
--theme-border: #d7d2c7;
--theme-border-light: #e7e2d8;
--theme-success: #25845f;
--theme-success-bg: #e3f4ec;
--theme-error: #c44747;
--theme-error-bg: #fae8e5;
--theme-badge-bg: #e8e5ff;
--theme-badge-text: #4840bb;
--theme-danger: #c44747;
--theme-danger-hover: #a93636;
--theme-shadow-card: 0 1px 2px rgba(23, 32, 51, 0.04), 0 8px 24px rgba(23, 32, 51, 0.055);
--theme-shadow-card-hover: 0 2px 4px rgba(23, 32, 51, 0.05), 0 18px 40px rgba(23, 32, 51, 0.1);
--theme-shadow-modal: 0 28px 80px rgba(23, 32, 51, 0.22);
}
:root.dark {
--theme-bg-base: #121212;
--theme-bg-surface: #1e1e1e;
--theme-bg-surface-alt: #2d2d2d;
--theme-bg-callout: #1a2723;
--theme-primary: #4ab2b2;
--theme-primary-hover: #5ec7c7;
--theme-primary-light: #6cd1d1;
--theme-text-heading: #f9fafb;
--theme-text-body: #e5e7eb;
--theme-text-secondary: #9ca3af;
--theme-text-muted: #6b7280;
--theme-border: #374151;
--theme-border-light: #2d3748;
--theme-success: #4ade80;
--theme-success-bg: #064e3b;
--theme-error: #f87171;
--theme-error-bg: #7f1d1d;
--theme-badge-bg: #1f2937;
--theme-badge-text: #5ec7c7;
--theme-danger: #ef4444;
--theme-danger-hover: #f87171;
--theme-bg-base: #0d1117;
--theme-bg-surface: #151b23;
--theme-bg-surface-alt: #1c2430;
--theme-bg-callout: #25234a;
--theme-primary: #8b83ff;
--theme-primary-hover: #a49eff;
--theme-primary-light: #b2adff;
--theme-accent: #ff8a75;
--theme-text-heading: #f6f3ec;
--theme-text-body: #d8dde6;
--theme-text-secondary: #aab3c0;
--theme-text-muted: #7f8998;
--theme-border: #354050;
--theme-border-light: #293342;
--theme-success: #58c596;
--theme-success-bg: #173c30;
--theme-error: #ff8177;
--theme-error-bg: #472424;
--theme-badge-bg: #2d2959;
--theme-badge-text: #bbb7ff;
--theme-danger: #ff8177;
--theme-danger-hover: #ffa098;
--theme-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 10px 30px rgba(0, 0, 0, 0.18);
--theme-shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.36), 0 20px 44px rgba(0, 0, 0, 0.28);
--theme-shadow-modal: 0 30px 90px rgba(0, 0, 0, 0.55);
}
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.2);
--theme-shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.6), 0 2px 4px rgba(0, 0, 0, 0.4);
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
* {
border-color: var(--theme-border-light);
}
html {
background: var(--theme-bg-base);
scroll-behavior: smooth;
}
body {
min-width: 320px;
background:
radial-gradient(circle at 88% -10%, color-mix(in srgb, var(--theme-primary) 9%, transparent), transparent 32rem),
var(--theme-bg-base);
color: var(--theme-text-body);
font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button, a, input, textarea, select {
outline-color: var(--theme-primary);
}
:focus-visible {
outline: 3px solid color-mix(in srgb, var(--theme-primary) 45%, transparent);
outline-offset: 3px;
}
button, [role="button"], a {
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled), [role="button"], a {
cursor: pointer;
}
::selection {
background: color-mix(in srgb, var(--theme-primary) 24%, transparent);
}
}
@theme inline {
/* ── Sage / Teal palette mapped from CSS variables ── */
--color-bg-base: var(--theme-bg-base);
--color-bg-surface: var(--theme-bg-surface);
--color-bg-surface-alt: var(--theme-bg-surface-alt);
@ -67,6 +107,7 @@
--color-primary: var(--theme-primary);
--color-primary-hover: var(--theme-primary-hover);
--color-primary-light: var(--theme-primary-light);
--color-accent: var(--theme-accent);
--color-text-heading: var(--theme-text-heading);
--color-text-body: var(--theme-text-body);
--color-text-secondary: var(--theme-text-secondary);
@ -81,123 +122,72 @@
--color-badge-text: var(--theme-badge-text);
--color-danger: var(--theme-danger);
--color-danger-hover: var(--theme-danger-hover);
/* Typography */
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
/* Shadows */
--font-sans: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
--font-serif: var(--font-newsreader), ui-serif, Georgia, serif;
--shadow-card: var(--theme-shadow-card);
--shadow-card-hover: var(--theme-shadow-card-hover);
--shadow-modal: var(--theme-shadow-modal);
/* Animations */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
}
/* ── Base Styles ── */
body {
background: var(--color-bg-base);
color: var(--color-text-body);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
.app-page {
width: 100%;
max-width: 1280px;
margin-inline: auto;
padding: 2rem clamp(1rem, 3vw, 3rem) 5rem;
}
/* ── Scrollbar Styling ── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
.editorial-title {
font-family: var(--font-newsreader), Georgia, serif;
font-weight: 600;
letter-spacing: -0.035em;
}
/* ── Card flip animation ── */
.perspective-1000 {
perspective: 1000px;
.paper-grid {
background-image: radial-gradient(color-mix(in srgb, var(--theme-text-muted) 18%, transparent) 0.7px, transparent 0.7px);
background-size: 18px 18px;
}
.preserve-3d {
transform-style: preserve-3d;
}
.perspective-1000 { perspective: 1000px; }
.preserve-3d { transform-style: preserve-3d; }
.backface-hidden { backface-visibility: hidden; }
.rotate-y-180 { transform: rotateY(180deg); }
.rotate-x-180 { transform: rotateX(180deg); }
.backface-hidden {
backface-visibility: hidden;
}
@keyframes swipeLeft { to { transform: translateX(-44px) rotate(-6deg); opacity: 0; } }
@keyframes swipeRight { to { transform: translateX(44px) rotate(6deg); opacity: 0; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(14px) scale(.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes subtlePulse { 0%, 100% { opacity: 1; } 50% { opacity: .58; } }
.rotate-y-180 {
transform: rotateY(180deg);
}
.animate-swipe-left { animation: swipeLeft .36s var(--ease-spring) forwards; }
.animate-swipe-right { animation: swipeRight .36s var(--ease-spring) forwards; }
.animate-fade-in { animation: fadeIn .2s ease-out; }
.animate-slide-up { animation: slideUp .32s var(--ease-spring); }
.animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; }
.rotate-x-180 {
transform: rotateX(180deg);
}
/* ── Swipe animations ── */
@keyframes swipeLeft {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
}
@keyframes swipeRight {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(40px) rotate(8deg); opacity: 0; }
}
.animate-swipe-left {
animation: swipeLeft 0.4s var(--ease-spring) forwards;
}
.animate-swipe-right {
animation: swipeRight 0.4s var(--ease-spring) forwards;
}
/* ── Modal animation ── */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-slide-up {
animation: slideUp 0.3s var(--ease-spring);
}
/* ── Subtle pulse for loading states ── */
@keyframes subtlePulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.animate-subtle-pulse {
animation: subtlePulse 2s ease-in-out infinite;
}
/* ── Markdown content styles ── */
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
.markdown-content p { margin-bottom: 0.75rem; line-height: 1.7; }
.markdown-content ul { list-style: disc inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
.markdown-content ol { list-style: decimal inside; padding-left: 0; margin-bottom: 0.75rem; text-align: inherit; }
.markdown-content li { margin-bottom: 0.25rem; line-height: 1.6; }
.markdown-content strong { font-weight: 600; color: var(--color-text-heading); }
.markdown-content code { background: var(--color-bg-surface-alt); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: 0.75rem; }
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 600; text-align: left; padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
.markdown-content td { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-light); }
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); }
.markdown-content p { margin-bottom: .75rem; line-height: 1.72; }
.markdown-content ul { list-style: disc; padding-left: 1.35rem; margin-bottom: .75rem; }
.markdown-content ol { list-style: decimal; padding-left: 1.35rem; margin-bottom: .75rem; }
.markdown-content li { margin-bottom: .25rem; line-height: 1.65; }
.markdown-content strong { font-weight: 700; color: var(--color-text-heading); }
.markdown-content code { background: var(--color-bg-surface-alt); padding: .15rem .4rem; border-radius: .35rem; font-size: .875rem; }
.markdown-content table { border-collapse: collapse; width: 100%; margin-bottom: .75rem; }
.markdown-content th { background: var(--color-bg-surface-alt); font-weight: 650; text-align: left; padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
.markdown-content td { padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
.markdown-content *:last-child { margin-bottom: 0; }
@media (max-width: 767px) {
.app-page { padding-top: 1.25rem; padding-bottom: 6rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

View file

@ -1,16 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Manrope, Newsreader } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
const manrope = Manrope({
variable: "--font-manrope",
subsets: ["latin"],
display: "swap",
});
const newsreader = Newsreader({
variable: "--font-newsreader",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Study App",
description: "Practice exams and quizzes",
title: "Study Desk",
description: "A focused workspace for flashcards and practice quizzes",
};
export default function RootLayout({
@ -19,23 +26,21 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
<body className="min-h-full flex flex-col font-sans antialiased">
<Script id="theme-init" strategy="beforeInteractive">
{`
try {
if (localStorage.theme === 'dark') {
const savedTheme = localStorage.theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
} catch (_) {}
`,
}}
/>
</head>
<body className="min-h-full flex flex-col font-sans antialiased">
`}
</Script>
{children}
</body>
</html>

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
export default function LoginPage() {
const [password, setPassword] = useState("");
@ -55,37 +56,28 @@ export default function LoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
<div className="absolute inset-0 paper-grid opacity-70" />
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
<div className="relative w-full max-w-md animate-fade-in">
{/* Logo / Title area */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
<svg
className="w-8 h-8 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-text-heading">
{isSetup ? "Welcome to Study App!" : "Study App"}
<div className="mb-7 text-center">
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
<h1 className="editorial-title mt-2 text-4xl text-text-heading">
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
</h1>
<p className="text-text-muted mt-1 text-sm">
{isSetup
? "Please set your admin password for the first time."
: "Enter your password to continue"}
: "Open your notes, decks, and practice quizzes."}
</p>
</div>
{/* Login card */}
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
@ -101,7 +93,7 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
placeholder={isSetup ? "Create a new password" : "Enter your password"}
autoFocus
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary"
/>
</div>
@ -127,7 +119,7 @@ export default function LoginPage() {
<button
type="submit"
disabled={loading || !password}
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? (
<span className="inline-flex items-center gap-2">

View file

@ -1,5 +1,5 @@
import { getShareLink } from "@/services/shareService";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import Link from "next/link";
import { SharedViewer } from "./SharedViewer";
@ -27,6 +27,16 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
notFound();
}
// Older inherited-share URLs used a group token with a deck/quiz path.
// They did not include an item ID, so preserve them by sending visitors to
// the owning group where the intended item can be selected.
if ((type === "flashcards" || type === "quizzes") && link.group) {
if (link.group.class.slug !== classSlug) {
notFound();
}
redirect(`/shared/${classSlug}/groups/${token}`);
}
// Validate that the link matches the URL structure
if (type === "flashcards" && !link.deck) notFound();
if (type === "quizzes" && !link.quizSet) notFound();
@ -62,13 +72,14 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
return (
<div className="min-h-screen bg-bg-base flex flex-col">
{/* Read-only Header */}
<header className="bg-bg-surface border-b border-border-light shadow-sm sticky top-0 z-10">
<header className="sticky top-0 z-10 border-b border-border-light bg-bg-surface/88 shadow-sm backdrop-blur-xl">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<div className="flex items-center gap-2.5 text-text-heading font-semibold">
<div className="flex items-center gap-2.5 font-semibold text-text-heading">
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="grid h-8 w-8 place-items-center rounded-xl bg-primary text-xs font-black text-white">S</span>
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
</div>
@ -92,7 +103,7 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{/* Main Content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
{type === "groups" && !itemId ? (
<SharedGroupViewer data={targetData} />
) : (

View file

@ -79,7 +79,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</span>
<button
onClick={() => setShowAdd(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl bg-primary px-4 text-sm font-bold text-white transition-colors hover:bg-primary-hover"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
@ -92,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
{cards.map((card, index) => (
<div
key={card.id}
className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden"
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
>
{editingId === card.id ? (
<div className="p-4 space-y-3">
@ -136,7 +136,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</div>
) : (
<div className="flex items-stretch">
<div className="flex-1 p-4 grid grid-cols-2 gap-4">
<div className="grid flex-1 grid-cols-1 gap-4 p-4 sm:grid-cols-2">
<div>
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
Front

View file

@ -277,13 +277,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (completed) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 max-w-md w-full text-center">
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-text-heading mb-2">Set Complete!</h3>
<h3 className="editorial-title mb-2 text-3xl text-text-heading">Set complete</h3>
<div className="flex justify-center gap-6 mb-6">
<div className="text-center">
@ -334,7 +334,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
return (
<div className="flex flex-col items-center">
{/* Running tally */}
<div className="w-full max-w-4xl mb-6">
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-success font-medium">{correctCount} </span>
@ -371,16 +371,16 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
>
<div
key={currentCard.id}
className={`relative w-full min-h-[450px] md:min-h-[550px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
className={`relative w-full min-h-[420px] md:min-h-[560px] preserve-3d ${!swipeClass ? "transition-transform duration-300" : ""} ${
isFlipped ? "rotate-x-180" : ""
}`}
>
{/* Front */}
<div className="absolute inset-0 backface-hidden bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="paper-grid absolute inset-0 flex flex-col items-center justify-center rounded-[2rem] border border-border-light bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Front
</span>
<div className="markdown-content text-center text-xl md:text-3xl text-text-heading leading-relaxed w-full">
<div className="markdown-content w-full text-center font-serif text-2xl font-semibold leading-relaxed text-text-heading md:text-4xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentCard.front}
</ReactMarkdown>
@ -391,7 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</div>
{/* Back */}
<div className="absolute inset-0 backface-hidden rotate-x-180 bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 md:p-12 flex flex-col items-center justify-center">
<div className="paper-grid absolute inset-0 flex rotate-x-180 flex-col items-center justify-center rounded-[2rem] border border-primary/25 bg-bg-surface p-7 shadow-[var(--shadow-card-hover)] backface-hidden md:p-12">
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
Back
</span>
@ -437,12 +437,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
<div className="w-full mt-6">
{/* Primary Action Row (Grading) */}
<div className={`flex items-center justify-center gap-4 min-h-[52px] mb-4 relative z-10 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
<div className={`relative z-10 mb-4 flex min-h-[56px] items-center justify-center gap-3 sm:gap-4 ${!hasFlippedOnce ? 'pointer-events-none' : ''}`}>
{hasFlippedOnce && (
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
<button
onClick={() => gradeCard("missed")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-error/30 text-error font-medium hover:bg-error-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -451,7 +451,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</button>
<button
onClick={() => gradeCard("correct")}
className="flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-success/30 text-success font-medium hover:bg-success-bg transition-all duration-200 cursor-pointer bg-bg-base/80 backdrop-blur"
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />

View file

@ -21,7 +21,7 @@ export function ImportModal({
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
@ -29,14 +29,15 @@ export function ImportModal({
/>
{/* Modal */}
<div className="relative w-full max-w-2xl max-h-[90vh] bg-bg-surface rounded-2xl shadow-[var(--shadow-modal)] animate-slide-up flex flex-col">
<div className="relative flex max-h-[92vh] w-full max-w-2xl animate-slide-up flex-col rounded-t-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-modal)] sm:rounded-[2rem]">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-0">
<h2 className="text-lg font-semibold text-text-heading">
<h2 className="editorial-title text-2xl text-text-heading">
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
</h2>
<button
onClick={onClose}
aria-label="Close import dialog"
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -50,7 +50,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
{/* Top Banner */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-8 text-center relative overflow-hidden">
<div className="relative overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface p-6 text-center shadow-[var(--shadow-card-hover)] sm:p-8">
{percentage >= 80 && (
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
)}
@ -61,7 +61,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<div className="absolute top-0 left-0 w-full h-2 bg-error"></div>
)}
<h2 className="text-2xl font-bold text-text-heading mb-2">Quiz Complete</h2>
<h2 className="editorial-title mb-2 text-4xl text-text-heading">Quiz complete</h2>
{attempt.isPartialRetake && (
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
)}
@ -71,7 +71,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
</div>
<div className="flex justify-center gap-4">
<div className="flex flex-col justify-center gap-3 sm:flex-row">
{onClose && (
<button
onClick={onClose}

View file

@ -320,10 +320,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
)}
{/* Main Unified Card */}
<div className="w-full max-w-3xl bg-bg-surface-alt rounded-2xl border border-border-light shadow-[var(--shadow-card)] overflow-hidden mb-8 mt-12">
<div className="mb-8 mt-4 w-full max-w-4xl overflow-hidden rounded-[2rem] border border-border-light bg-bg-surface shadow-[var(--shadow-card-hover)] md:mt-8">
{/* Progress Header */}
<div className="px-6 md:px-8 py-5 flex items-center gap-4">
<div className="flex items-center gap-4 border-b border-border-light bg-bg-surface-alt/55 px-5 py-4 md:px-8">
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
Q {currentIndex + 1} / {order.length}
</div>
@ -339,7 +339,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
</div>
{/* Question Area */}
<div className="px-6 md:px-8 pb-8 pt-2">
<div className="px-5 pb-6 pt-6 md:px-10 md:pb-10 md:pt-8">
{/* Tags */}
<div className="flex items-center justify-between mb-8">
@ -352,7 +352,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
</div>
{/* Prompt */}
<div className="markdown-content text-xl text-text-heading mb-8 font-serif leading-relaxed font-semibold">
<div className="markdown-content editorial-title mb-8 text-2xl leading-relaxed text-text-heading md:text-3xl">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{currentQ.prompt}
</ReactMarkdown>
@ -392,7 +392,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
<div
key={opt.id}
onClick={() => toggleOption(opt.id)}
className={`flex items-center gap-4 px-5 py-4 rounded-xl border transition-all duration-200 ${stateClass}`}
className={`flex min-h-14 items-center gap-4 rounded-2xl border px-4 py-4 transition-all duration-200 md:px-5 ${stateClass}`}
>
<div className="flex-shrink-0">
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
@ -478,7 +478,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
})()}
{/* Action Button */}
<div className="mt-10 flex">
<div className="sticky bottom-3 z-10 mt-10 flex rounded-2xl bg-bg-surface/90 p-1 backdrop-blur md:static md:bg-transparent md:p-0">
{!isSubmitted ? (
<button
onClick={handleSubmit}

View file

@ -24,17 +24,21 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
return (
<>
<div className="mb-6">
<div className="mb-7 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors"
className="mb-3 inline-flex min-h-10 items-center gap-2 rounded-lg pr-3 text-xs font-bold uppercase tracking-[0.15em] text-text-muted transition-colors hover:text-primary"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
All Classes
Library
</Link>
<h1 className="text-2xl font-bold text-text-heading">{className}</h1>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Active class</p>
<h1 className="editorial-title mt-1 text-4xl text-text-heading sm:text-5xl">{className}</h1>
</div>
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
</div>
<ClassTabs classSlug={classSlug} />

View file

@ -12,8 +12,8 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
const pathname = usePathname();
return (
<div className="border-b border-border-light mb-6">
<nav className="flex gap-0" aria-label="Study modes">
<div className="mb-8 border-b border-border-light">
<nav className="flex gap-2" aria-label="Study modes">
{STUDY_MODES.map((mode) => {
const href = `/${classSlug}/${mode.path}`;
const isActive = pathname === href || pathname.startsWith(href + "/");
@ -22,7 +22,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
<Link
key={mode.key}
href={href}
className={`relative px-5 py-3 text-sm font-medium transition-colors duration-200 ${
className={`relative min-h-12 px-4 py-3 text-sm font-bold transition-colors duration-200 ${
isActive
? "text-primary"
: "text-text-muted hover:text-text-heading"
@ -30,7 +30,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
>
{mode.label}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
<span className="absolute bottom-0 left-2 right-2 h-[3px] rounded-t-full bg-primary" />
)}
</Link>
);

View file

@ -1,11 +1,24 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
function Mark() {
return (
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-sm font-extrabold text-white shadow-[0_8px_22px_color-mix(in_srgb,var(--theme-primary)_28%,transparent)]">
S
</span>
);
}
export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const parts = pathname.split("/").filter(Boolean);
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
@ -13,40 +26,82 @@ export function Navbar() {
router.refresh();
}
return (
<nav className="bg-bg-surface border-b border-border-light">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
<Link
href="/"
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
>
<svg
className="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
const nav = (
<>
<Link href="/" onClick={() => setOpen(false)} className="mb-9 flex items-center gap-3">
<Mark />
<span>
<span className="block text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Your</span>
<span className="editorial-title block text-xl leading-none text-text-heading">Study Desk</span>
</span>
</Link>
<nav className="space-y-1" aria-label="Primary navigation">
<Link
href="/"
onClick={() => setOpen(false)}
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname === "/" ? "bg-primary text-white shadow-sm" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<span aria-hidden className="text-lg"></span> Library
</Link>
{classSlug && (
<div className="pt-6">
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-text-muted">Current class</p>
<Link
href={`/${classSlug}/flashcards`}
onClick={() => setOpen(false)}
className={`flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/flashcards") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Study
</Link>
<div className="flex items-center gap-4">
<ThemeToggle />
<button
onClick={handleLogout}
className="text-sm text-text-muted hover:text-text-heading transition-colors cursor-pointer"
<span aria-hidden></span> Flashcards
</Link>
<Link
href={`/${classSlug}/quizzes`}
onClick={() => setOpen(false)}
className={`mt-1 flex min-h-11 items-center gap-3 rounded-xl px-3 text-sm font-semibold transition-colors ${pathname.includes("/quizzes") ? "bg-bg-callout text-primary" : "text-text-secondary hover:bg-bg-surface-alt hover:text-text-heading"}`}
>
Sign out
</button>
<span aria-hidden></span> Quizzes
</Link>
</div>
)}
</nav>
<div className="mt-auto border-t border-border-light pt-4">
<div className="flex items-center justify-between rounded-xl px-2 py-1.5">
<span className="text-xs font-semibold text-text-muted">Appearance</span>
<ThemeToggle />
</div>
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error">
<span aria-hidden></span> Sign out
</button>
</div>
</nav>
</>
);
return (
<>
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-border-light bg-bg-surface/88 px-4 py-5 backdrop-blur-xl md:flex">
{nav}
</aside>
<header className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border-light bg-bg-surface/88 px-4 backdrop-blur-xl md:hidden">
<Link href="/" className="flex items-center gap-2.5">
<Mark />
<span className="editorial-title text-lg text-text-heading">Study Desk</span>
</Link>
<button onClick={() => setOpen(true)} className="grid h-11 w-11 place-items-center rounded-xl border border-border-light bg-bg-surface-alt text-xl text-text-heading" aria-label="Open navigation" aria-expanded={open}>
</button>
</header>
{open && (
<div className="fixed inset-0 z-50 md:hidden">
<button className="absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={() => setOpen(false)} aria-label="Close navigation" />
<aside className="animate-slide-up absolute inset-y-0 right-0 flex w-[min(86vw,21rem)] flex-col border-l border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
<button onClick={() => setOpen(false)} className="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-xl text-xl text-text-muted hover:bg-bg-surface-alt" aria-label="Close navigation">×</button>
{nav}
</aside>
</div>
)}
</>
);
}

View file

@ -45,8 +45,15 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
async function handleCopy() {
if (!token) return;
const typeStr = targetType === "GROUP" ? "groups" : (targetType === "DECK" ? "flashcards" : "quizzes");
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
const typeStr = isGroupShared
? "groups"
: targetType === "GROUP"
? "groups"
: targetType === "DECK"
? "flashcards"
: "quizzes";
const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : "";
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@ -56,7 +63,7 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
className="grid h-10 w-10 place-items-center rounded-xl text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary"
title="Share"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -67,8 +74,8 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-72 bg-bg-surface rounded-xl shadow-[var(--shadow-modal)] border border-border-light z-50 p-4 animate-slide-up origin-top-right">
<h4 className="text-sm font-semibold text-text-heading mb-3">Public Sharing</h4>
<div className="absolute right-0 z-50 mt-2 w-72 origin-top-right animate-slide-up rounded-2xl border border-border-light bg-bg-surface p-5 shadow-[var(--shadow-modal)]">
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
{loading ? (
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />

View file

@ -12,7 +12,7 @@ export function ThemeToggle() {
}, []);
if (!mounted) {
return <div className="w-8 h-8" />; // placeholder to prevent layout shift
return <div className="h-10 w-10" />; // placeholder to prevent layout shift
}
function toggleTheme() {
@ -30,9 +30,9 @@ export function ThemeToggle() {
return (
<button
onClick={toggleTheme}
className="p-1.5 text-text-muted hover:text-text-heading transition-colors cursor-pointer rounded-lg hover:bg-bg-surface-alt flex items-center justify-center"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-light bg-bg-surface-alt text-text-muted transition-colors hover:border-primary/30 hover:text-primary"
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
aria-label="Toggle Dark Mode"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">