New: Ui Redesign
This commit is contained in:
parent
009d1eb065
commit
631dfa5923
22 changed files with 464 additions and 524 deletions
BIN
dev.db
BIN
dev.db
Binary file not shown.
|
|
@ -90,9 +90,9 @@ export default function DeckStudyPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/${classSlug}/flashcards`)}
|
onClick={() => router.push(`/${classSlug}/flashcards`)}
|
||||||
|
|
@ -103,7 +103,8 @@ export default function DeckStudyPage() {
|
||||||
</svg>
|
</svg>
|
||||||
Back to decks
|
Back to decks
|
||||||
</button>
|
</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 && (
|
{deck.description && (
|
||||||
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
|
<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">
|
<div className="flex items-center gap-2 md:gap-4 ml-auto">
|
||||||
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
|
<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
|
<button
|
||||||
onClick={() => setView("study")}
|
onClick={() => setView("study")}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
||||||
const progressLabel = getProgressLabel(deck);
|
const progressLabel = getProgressLabel(deck);
|
||||||
|
|
||||||
return (
|
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 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="p-4 flex flex-col flex-1 h-full">
|
<div className="flex h-full flex-1 flex-col p-5">
|
||||||
<div className="flex items-start gap-2">
|
<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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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>}
|
{deck.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -369,15 +369,15 @@ export default function FlashcardsPage() {
|
||||||
const activeDeck = decks.find(d => d.id === activeId);
|
const activeDeck = decks.find(d => d.id === activeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="pb-20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
|
<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="flex items-center gap-3">
|
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||||
<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">
|
<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
|
Add Group
|
||||||
</button>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|
@ -387,7 +387,7 @@ export default function FlashcardsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreatingGroup && (
|
{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
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -407,8 +407,8 @@ export default function FlashcardsPage() {
|
||||||
|
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
<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">
|
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||||
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Deck</h3>
|
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit deck</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|
@ -443,7 +443,7 @@ export default function FlashcardsPage() {
|
||||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedDecks.map(group => (
|
{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"}`}>
|
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
|
||||||
{editingGroupId === group.id ? (
|
{editingGroupId === group.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<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"}>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -484,7 +484,7 @@ export default function FlashcardsPage() {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Uncategorized */}
|
{/* 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"}`}>
|
<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"}>
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Dynamic Header & Tabs */}
|
||||||
<ClassHeader classSlug={classSlug} className={classData.name} />
|
<ClassHeader classSlug={classSlug} className={classData.name} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,9 @@ export default function QuizStudyPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/${classSlug}/quizzes`)}
|
onClick={() => router.push(`/${classSlug}/quizzes`)}
|
||||||
|
|
@ -142,7 +142,7 @@ export default function QuizStudyPage() {
|
||||||
onClick={() => setShowTopics(!showTopics)}
|
onClick={() => setShowTopics(!showTopics)}
|
||||||
className="flex items-center gap-2 group cursor-pointer text-left focus:outline-none mb-2"
|
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}
|
{quiz.name}
|
||||||
</h1>
|
</h1>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -180,7 +180,7 @@ export default function QuizStudyPage() {
|
||||||
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
|
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
|
||||||
|
|
||||||
{attempts.length > 0 && (
|
{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
|
<button
|
||||||
onClick={() => setView("history")}
|
onClick={() => setView("history")}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 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="p-4 flex flex-col flex-1 h-full">
|
<div className="flex h-full flex-1 flex-col p-5">
|
||||||
<div className="flex items-start gap-2">
|
<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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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>}
|
{quiz.description && <p className="text-sm text-text-secondary mb-3 line-clamp-2">{quiz.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -343,15 +343,15 @@ export default function QuizzesPage() {
|
||||||
const activeQuiz = quizzes.find(q => q.id === activeId);
|
const activeQuiz = quizzes.find(q => q.id === activeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="pb-20">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
|
<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="flex items-center gap-3">
|
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||||
<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">
|
<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
|
Add Group
|
||||||
</button>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
|
|
@ -361,7 +361,7 @@ export default function QuizzesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreatingGroup && (
|
{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
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -382,8 +382,8 @@ export default function QuizzesPage() {
|
||||||
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
|
{/* Editing quiz modal/inline - simplified as a modal-like overlay for simplicity when editing */}
|
||||||
{editingId && (
|
{editingId && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
|
<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">
|
<div className="w-full max-w-md rounded-3xl border border-border bg-bg-surface p-6 shadow-[var(--shadow-modal)]">
|
||||||
<h3 className="text-lg font-semibold text-text-heading mb-4">Edit Quiz</h3>
|
<h3 className="editorial-title mb-4 text-2xl text-text-heading">Edit quiz</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|
@ -418,7 +418,7 @@ export default function QuizzesPage() {
|
||||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedQuizzes.map(group => (
|
{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"}`}>
|
<div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
|
||||||
{editingGroupId === group.id ? (
|
{editingGroupId === group.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<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"}>
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -459,7 +459,7 @@ export default function QuizzesPage() {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Uncategorized */}
|
{/* 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"}`}>
|
<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"}>
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ export default async function ProtectedLayout({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="min-h-screen md:ml-60">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
interface ClassItem {
|
interface ClassItem {
|
||||||
|
|
@ -10,6 +10,8 @@ interface ClassItem {
|
||||||
_count: { decks: number; quizSets: number };
|
_count: { decks: number; quizSets: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"];
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -20,256 +22,128 @@ export default function HomePage() {
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchClasses(); }, []);
|
||||||
fetchClasses();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function fetchClasses() {
|
async function fetchClasses() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/classes");
|
const res = await fetch("/api/classes");
|
||||||
const data = await res.json();
|
setClasses(await res.json());
|
||||||
setClasses(data);
|
} finally { setLoading(false); }
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/classes", {
|
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
|
||||||
method: "POST",
|
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
||||||
headers: { "Content-Type": "application/json" },
|
} finally { setCreating(false); }
|
||||||
body: JSON.stringify({ name: newName.trim() }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setNewName("");
|
|
||||||
setShowCreate(false);
|
|
||||||
fetchClasses();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string, name: string) {
|
async function handleDelete(id: string, name: string) {
|
||||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||||
|
|
||||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||||
fetchClasses();
|
fetchClasses();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveEdit(id: string) {
|
async function handleSaveEdit(id: string) {
|
||||||
if (!editName.trim()) {
|
if (!editName.trim()) { setEditingId(null); return; }
|
||||||
setEditingId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/classes/${id}`, {
|
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
|
||||||
method: "PATCH",
|
if (res.ok) { setEditingId(null); fetchClasses(); }
|
||||||
headers: { "Content-Type": "application/json" },
|
} finally { setSavingEdit(false); }
|
||||||
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 (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="app-page">
|
||||||
{/* Header */}
|
<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="flex items-center justify-between mb-8">
|
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/70 blur-3xl" />
|
||||||
<div>
|
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/35 blur-3xl" />
|
||||||
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
|
<div className="relative flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<p className="text-text-secondary mt-1">
|
<div>
|
||||||
Select a class to study flashcards or take quizzes
|
<p className="mb-3 text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
|
||||||
</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>
|
</div>
|
||||||
<button
|
<div className="relative mt-8 grid max-w-xl grid-cols-3 gap-3 border-t border-white/12 pt-6">
|
||||||
onClick={() => setShowCreate(!showCreate)}
|
{[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => (
|
||||||
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"
|
<div key={label}>
|
||||||
>
|
<div className="text-2xl font-extrabold sm:text-3xl">{value}</div>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="mt-1 text-xs font-semibold text-white/50">{label}</div>
|
||||||
<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>
|
</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 */}
|
<div className="mb-5 flex items-end justify-between gap-4">
|
||||||
{!loading && classes.length === 0 && (
|
<div>
|
||||||
<div className="text-center py-16">
|
<p className="text-xs font-bold uppercase tracking-[0.18em] text-primary">Study spaces</p>
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
|
<h2 className="editorial-title mt-1 text-3xl text-text-heading">Your classes</h2>
|
||||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</div>
|
||||||
<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" />
|
<p className="hidden text-sm text-text-muted sm:block">Flashcards and quizzes, organized by class.</p>
|
||||||
</svg>
|
</div>
|
||||||
</div>
|
|
||||||
<h2 className="text-lg font-semibold text-text-heading mb-1">
|
{loading && (
|
||||||
No classes yet
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
</h2>
|
{[1, 2, 3].map((i) => <div key={i} className="h-52 animate-subtle-pulse rounded-2xl border border-border-light bg-bg-surface" />)}
|
||||||
<p className="text-text-secondary mb-4">
|
</div>
|
||||||
Create your first class to start studying
|
)}
|
||||||
</p>
|
|
||||||
<button
|
{!loading && classes.length === 0 && (
|
||||||
onClick={() => setShowCreate(true)}
|
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||||
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"
|
<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>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<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>
|
||||||
</svg>
|
|
||||||
Add Your First Class
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Class grid */}
|
|
||||||
{!loading && classes.length > 0 && (
|
{!loading && classes.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{classes.map((cls) => (
|
{classes.map((cls, index) => (
|
||||||
<div
|
<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)]">
|
||||||
key={cls.id}
|
<div className="h-1.5" style={{ backgroundColor: accents[index % accents.length] }} />
|
||||||
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 min-h-48 p-5 sm:p-6">
|
||||||
>
|
<div className="mb-8 flex items-start justify-between gap-4">
|
||||||
<Link
|
<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>
|
||||||
href={`/${cls.slug}`}
|
<span className="text-xl text-text-muted transition-transform group-hover:translate-x-1" aria-hidden>→</span>
|
||||||
className="block p-6"
|
</div>
|
||||||
>
|
|
||||||
{editingId === cls.id ? (
|
{editingId === cls.id ? (
|
||||||
<input
|
<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" />
|
||||||
type="text"
|
) : <h3 className="text-xl font-extrabold text-text-heading group-hover:text-primary">{cls.name}</h3>}
|
||||||
value={editName}
|
<div className="mt-3 flex flex-wrap gap-2 text-xs font-semibold text-text-secondary">
|
||||||
onClick={(e) => {
|
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"}</span>
|
||||||
e.preventDefault();
|
<span className="rounded-full bg-bg-surface-alt px-2.5 py-1">{cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"}</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
<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">
|
||||||
{/* Actions */}
|
<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>
|
||||||
<div className="absolute top-3 right-3 flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
<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>
|
||||||
{/* 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>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,64 +2,104 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--theme-bg-base: #f0f4f2;
|
--theme-bg-base: #f5f2ea;
|
||||||
--theme-bg-surface: #ffffff;
|
--theme-bg-surface: #fffdf8;
|
||||||
--theme-bg-surface-alt: #f4f7f5;
|
--theme-bg-surface-alt: #eeeae0;
|
||||||
--theme-bg-callout: #e8f4f0;
|
--theme-bg-callout: #ebe9ff;
|
||||||
--theme-primary: #2a7d7d;
|
--theme-primary: #4f46e5;
|
||||||
--theme-primary-hover: #1f6363;
|
--theme-primary-hover: #3f37c9;
|
||||||
--theme-primary-light: #3a9e9e;
|
--theme-primary-light: #756ef0;
|
||||||
--theme-text-heading: #1a2f2f;
|
--theme-accent: #f97360;
|
||||||
--theme-text-body: #2d4a4a;
|
--theme-text-heading: #172033;
|
||||||
--theme-text-secondary: #4a6a6a;
|
--theme-text-body: #344054;
|
||||||
--theme-text-muted: #7a9a9a;
|
--theme-text-secondary: #667085;
|
||||||
--theme-border: #c8d8d0;
|
--theme-text-muted: #8b91a0;
|
||||||
--theme-border-light: #dce8e2;
|
--theme-border: #d7d2c7;
|
||||||
--theme-success: #2d8a4e;
|
--theme-border-light: #e7e2d8;
|
||||||
--theme-success-bg: #e8f5ed;
|
--theme-success: #25845f;
|
||||||
--theme-error: #c44c4c;
|
--theme-success-bg: #e3f4ec;
|
||||||
--theme-error-bg: #fce8e8;
|
--theme-error: #c44747;
|
||||||
--theme-badge-bg: #e0eeea;
|
--theme-error-bg: #fae8e5;
|
||||||
--theme-badge-text: #2a6a5a;
|
--theme-badge-bg: #e8e5ff;
|
||||||
--theme-danger: #dc3545;
|
--theme-badge-text: #4840bb;
|
||||||
--theme-danger-hover: #c82333;
|
--theme-danger: #c44747;
|
||||||
|
--theme-danger-hover: #a93636;
|
||||||
--theme-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
--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 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
--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 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
|
--theme-shadow-modal: 0 28px 80px rgba(23, 32, 51, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--theme-bg-base: #121212;
|
--theme-bg-base: #0d1117;
|
||||||
--theme-bg-surface: #1e1e1e;
|
--theme-bg-surface: #151b23;
|
||||||
--theme-bg-surface-alt: #2d2d2d;
|
--theme-bg-surface-alt: #1c2430;
|
||||||
--theme-bg-callout: #1a2723;
|
--theme-bg-callout: #25234a;
|
||||||
--theme-primary: #4ab2b2;
|
--theme-primary: #8b83ff;
|
||||||
--theme-primary-hover: #5ec7c7;
|
--theme-primary-hover: #a49eff;
|
||||||
--theme-primary-light: #6cd1d1;
|
--theme-primary-light: #b2adff;
|
||||||
--theme-text-heading: #f9fafb;
|
--theme-accent: #ff8a75;
|
||||||
--theme-text-body: #e5e7eb;
|
--theme-text-heading: #f6f3ec;
|
||||||
--theme-text-secondary: #9ca3af;
|
--theme-text-body: #d8dde6;
|
||||||
--theme-text-muted: #6b7280;
|
--theme-text-secondary: #aab3c0;
|
||||||
--theme-border: #374151;
|
--theme-text-muted: #7f8998;
|
||||||
--theme-border-light: #2d3748;
|
--theme-border: #354050;
|
||||||
--theme-success: #4ade80;
|
--theme-border-light: #293342;
|
||||||
--theme-success-bg: #064e3b;
|
--theme-success: #58c596;
|
||||||
--theme-error: #f87171;
|
--theme-success-bg: #173c30;
|
||||||
--theme-error-bg: #7f1d1d;
|
--theme-error: #ff8177;
|
||||||
--theme-badge-bg: #1f2937;
|
--theme-error-bg: #472424;
|
||||||
--theme-badge-text: #5ec7c7;
|
--theme-badge-bg: #2d2959;
|
||||||
--theme-danger: #ef4444;
|
--theme-badge-text: #bbb7ff;
|
||||||
--theme-danger-hover: #f87171;
|
--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);
|
border-color: var(--theme-border-light);
|
||||||
--theme-shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
|
}
|
||||||
|
|
||||||
|
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 {
|
@theme inline {
|
||||||
/* ── Sage / Teal palette mapped from CSS variables ── */
|
|
||||||
--color-bg-base: var(--theme-bg-base);
|
--color-bg-base: var(--theme-bg-base);
|
||||||
--color-bg-surface: var(--theme-bg-surface);
|
--color-bg-surface: var(--theme-bg-surface);
|
||||||
--color-bg-surface-alt: var(--theme-bg-surface-alt);
|
--color-bg-surface-alt: var(--theme-bg-surface-alt);
|
||||||
|
|
@ -67,6 +107,7 @@
|
||||||
--color-primary: var(--theme-primary);
|
--color-primary: var(--theme-primary);
|
||||||
--color-primary-hover: var(--theme-primary-hover);
|
--color-primary-hover: var(--theme-primary-hover);
|
||||||
--color-primary-light: var(--theme-primary-light);
|
--color-primary-light: var(--theme-primary-light);
|
||||||
|
--color-accent: var(--theme-accent);
|
||||||
--color-text-heading: var(--theme-text-heading);
|
--color-text-heading: var(--theme-text-heading);
|
||||||
--color-text-body: var(--theme-text-body);
|
--color-text-body: var(--theme-text-body);
|
||||||
--color-text-secondary: var(--theme-text-secondary);
|
--color-text-secondary: var(--theme-text-secondary);
|
||||||
|
|
@ -81,123 +122,72 @@
|
||||||
--color-badge-text: var(--theme-badge-text);
|
--color-badge-text: var(--theme-badge-text);
|
||||||
--color-danger: var(--theme-danger);
|
--color-danger: var(--theme-danger);
|
||||||
--color-danger-hover: var(--theme-danger-hover);
|
--color-danger-hover: var(--theme-danger-hover);
|
||||||
|
--font-sans: var(--font-manrope), ui-sans-serif, system-ui, sans-serif;
|
||||||
/* Typography */
|
--font-serif: var(--font-newsreader), ui-serif, Georgia, serif;
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
||||||
|
|
||||||
/* Shadows */
|
|
||||||
--shadow-card: var(--theme-shadow-card);
|
--shadow-card: var(--theme-shadow-card);
|
||||||
--shadow-card-hover: var(--theme-shadow-card-hover);
|
--shadow-card-hover: var(--theme-shadow-card-hover);
|
||||||
--shadow-modal: var(--theme-shadow-modal);
|
--shadow-modal: var(--theme-shadow-modal);
|
||||||
|
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
/* Animations */
|
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base Styles ── */
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
body {
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
background: var(--color-bg-base);
|
::-webkit-scrollbar-thumb { background: var(--color-border); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||||
color: var(--color-text-body);
|
|
||||||
font-family: var(--font-sans);
|
.app-page {
|
||||||
-webkit-font-smoothing: antialiased;
|
width: 100%;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
max-width: 1280px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 2rem clamp(1rem, 3vw, 3rem) 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Scrollbar Styling ── */
|
.editorial-title {
|
||||||
::-webkit-scrollbar {
|
font-family: var(--font-newsreader), Georgia, serif;
|
||||||
width: 6px;
|
font-weight: 600;
|
||||||
}
|
letter-spacing: -0.035em;
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-border);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Card flip animation ── */
|
.paper-grid {
|
||||||
.perspective-1000 {
|
background-image: radial-gradient(color-mix(in srgb, var(--theme-text-muted) 18%, transparent) 0.7px, transparent 0.7px);
|
||||||
perspective: 1000px;
|
background-size: 18px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preserve-3d {
|
.perspective-1000 { perspective: 1000px; }
|
||||||
transform-style: preserve-3d;
|
.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 {
|
@keyframes swipeLeft { to { transform: translateX(-44px) rotate(-6deg); opacity: 0; } }
|
||||||
backface-visibility: hidden;
|
@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 {
|
.animate-swipe-left { animation: swipeLeft .36s var(--ease-spring) forwards; }
|
||||||
transform: rotateY(180deg);
|
.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 {
|
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); }
|
||||||
transform: rotateX(180deg);
|
.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; }
|
||||||
/* ── Swipe animations ── */
|
.markdown-content ul { list-style: disc; padding-left: 1.35rem; margin-bottom: .75rem; }
|
||||||
@keyframes swipeLeft {
|
.markdown-content ol { list-style: decimal; padding-left: 1.35rem; margin-bottom: .75rem; }
|
||||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
.markdown-content li { margin-bottom: .25rem; line-height: 1.65; }
|
||||||
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
|
.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; }
|
||||||
@keyframes swipeRight {
|
.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); }
|
||||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
.markdown-content td { padding: .6rem .8rem; border: 1px solid var(--color-border-light); }
|
||||||
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 *:last-child { margin-bottom: 0; }
|
.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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
import type { Metadata } from "next";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const manrope = Manrope({
|
||||||
variable: "--font-inter",
|
variable: "--font-manrope",
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const newsreader = Newsreader({
|
||||||
|
variable: "--font-newsreader",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Study App",
|
title: "Study Desk",
|
||||||
description: "Practice exams and quizzes",
|
description: "A focused workspace for flashcards and practice quizzes",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -19,23 +26,21 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
|
<html lang="en" className={`${manrope.variable} ${newsreader.variable} h-full`} suppressHydrationWarning>
|
||||||
<head>
|
<body className="min-h-full flex flex-col font-sans antialiased">
|
||||||
<script
|
<Script id="theme-init" strategy="beforeInteractive">
|
||||||
dangerouslySetInnerHTML={{
|
{`
|
||||||
__html: `
|
|
||||||
try {
|
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')
|
document.documentElement.classList.add('dark')
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove('dark')
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
`,
|
`}
|
||||||
}}
|
</Script>
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-full flex flex-col font-sans antialiased">
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
@ -55,37 +56,28 @@ export default function LoginPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center p-4">
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
||||||
<div className="w-full max-w-sm animate-fade-in">
|
<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 */}
|
{/* Logo / Title area */}
|
||||||
<div className="text-center mb-8">
|
<div className="mb-7 text-center">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
<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>
|
||||||
<svg
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
||||||
className="w-8 h-8 text-primary"
|
<h1 className="editorial-title mt-2 text-4xl text-text-heading">
|
||||||
fill="none"
|
{isSetup ? "Welcome to Study Desk" : "Welcome back"}
|
||||||
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"}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-text-muted mt-1 text-sm">
|
<p className="text-text-muted mt-1 text-sm">
|
||||||
{isSetup
|
{isSetup
|
||||||
? "Please set your admin password for the first time."
|
? "Please set your admin password for the first time."
|
||||||
: "Enter your password to continue"}
|
: "Open your notes, decks, and practice quizzes."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login card */}
|
{/* 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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|
@ -101,7 +93,7 @@ export default function LoginPage() {
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
placeholder={isSetup ? "Create a new password" : "Enter your password"}
|
||||||
autoFocus
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -127,7 +119,7 @@ export default function LoginPage() {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !password}
|
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 ? (
|
{loading ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getShareLink } from "@/services/shareService";
|
import { getShareLink } from "@/services/shareService";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SharedViewer } from "./SharedViewer";
|
import { SharedViewer } from "./SharedViewer";
|
||||||
|
|
@ -27,6 +27,16 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
notFound();
|
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
|
// Validate that the link matches the URL structure
|
||||||
if (type === "flashcards" && !link.deck) notFound();
|
if (type === "flashcards" && !link.deck) notFound();
|
||||||
if (type === "quizzes" && !link.quizSet) notFound();
|
if (type === "quizzes" && !link.quizSet) notFound();
|
||||||
|
|
@ -62,13 +72,14 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||||
{/* Read-only Header */}
|
{/* 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="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 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">
|
<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" />
|
<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>
|
</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")}
|
Shared {type === "groups" ? "Group" : (type === "flashcards" ? "Deck" : "Quiz")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -92,7 +103,7 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<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 ? (
|
{type === "groups" && !itemId ? (
|
||||||
<SharedGroupViewer data={targetData} />
|
<SharedGroupViewer data={targetData} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(true)}
|
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">
|
<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" />
|
<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) => (
|
{cards.map((card, index) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
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 ? (
|
{editingId === card.id ? (
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
|
|
@ -136,7 +136,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-stretch">
|
<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>
|
<div>
|
||||||
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
|
<span className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-1">
|
||||||
Front
|
Front
|
||||||
|
|
|
||||||
|
|
@ -277,13 +277,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
if (completed) {
|
if (completed) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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="flex justify-center gap-6 mb-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -334,7 +334,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{/* Running tally */}
|
{/* 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 justify-between text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-success font-medium">{correctCount} ✓</span>
|
<span className="text-success font-medium">{correctCount} ✓</span>
|
||||||
|
|
@ -371,16 +371,16 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
key={currentCard.id}
|
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" : ""
|
isFlipped ? "rotate-x-180" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Front */}
|
{/* 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">
|
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Front
|
Front
|
||||||
</span>
|
</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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{currentCard.front}
|
{currentCard.front}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -391,7 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Back */}
|
{/* 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">
|
<span className="absolute top-3 right-4 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||||
Back
|
Back
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -437,12 +437,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
<div className="w-full mt-6">
|
<div className="w-full mt-6">
|
||||||
|
|
||||||
{/* Primary Action Row (Grading) */}
|
{/* 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 && (
|
{hasFlippedOnce && (
|
||||||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => gradeCard("missed")}
|
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">
|
<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" />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => gradeCard("correct")}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function ImportModal({
|
||||||
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
|
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
|
||||||
|
|
@ -29,14 +29,15 @@ export function ImportModal({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* 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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 pt-5 pb-0">
|
<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"}
|
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
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">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<div className="max-w-3xl mx-auto space-y-8 animate-fade-in pb-12">
|
||||||
|
|
||||||
{/* Top Banner */}
|
{/* 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 && (
|
{percentage >= 80 && (
|
||||||
<div className="absolute top-0 left-0 w-full h-2 bg-success"></div>
|
<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>
|
<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 && (
|
{attempt.isPartialRetake && (
|
||||||
<p className="text-sm text-text-secondary mb-4">(Partial Retake)</p>
|
<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>
|
<span className="text-lg text-text-muted mb-1 font-medium">({parseFloat(attempt.score.toFixed(2))} / {attempt.maxScore})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex flex-col justify-center gap-3 sm:flex-row">
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
|
||||||
|
|
@ -320,10 +320,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Unified Card */}
|
{/* 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 */}
|
{/* 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">
|
<div className="text-sm font-mono text-text-secondary whitespace-nowrap tracking-wider">
|
||||||
Q {currentIndex + 1} / {order.length}
|
Q {currentIndex + 1} / {order.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,7 +339,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Question Area */}
|
{/* 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 */}
|
{/* Tags */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
|
@ -352,7 +352,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* 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]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{currentQ.prompt}
|
{currentQ.prompt}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
@ -392,7 +392,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
<div
|
<div
|
||||||
key={opt.id}
|
key={opt.id}
|
||||||
onClick={() => toggleOption(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="flex-shrink-0">
|
||||||
<div className={`w-5 h-5 rounded ${currentQ.type === 'SATA' ? 'border' : 'border rounded-full'} flex items-center justify-center transition-colors ${
|
<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 */}
|
{/* 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 ? (
|
{!isSubmitted ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,21 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
||||||
|
|
||||||
return (
|
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
|
<Link
|
||||||
href="/"
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
All Classes
|
Library
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ClassTabs classSlug={classSlug} />
|
<ClassTabs classSlug={classSlug} />
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border-light mb-6">
|
<div className="mb-8 border-b border-border-light">
|
||||||
<nav className="flex gap-0" aria-label="Study modes">
|
<nav className="flex gap-2" aria-label="Study modes">
|
||||||
{STUDY_MODES.map((mode) => {
|
{STUDY_MODES.map((mode) => {
|
||||||
const href = `/${classSlug}/${mode.path}`;
|
const href = `/${classSlug}/${mode.path}`;
|
||||||
const isActive = pathname === href || pathname.startsWith(href + "/");
|
const isActive = pathname === href || pathname.startsWith(href + "/");
|
||||||
|
|
@ -22,7 +22,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
||||||
<Link
|
<Link
|
||||||
key={mode.key}
|
key={mode.key}
|
||||||
href={href}
|
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
|
isActive
|
||||||
? "text-primary"
|
? "text-primary"
|
||||||
: "text-text-muted hover:text-text-heading"
|
: "text-text-muted hover:text-text-heading"
|
||||||
|
|
@ -30,7 +30,7 @@ export function ClassTabs({ classSlug }: ClassTabsProps) {
|
||||||
>
|
>
|
||||||
{mode.label}
|
{mode.label}
|
||||||
{isActive && (
|
{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>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useState } from "react";
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
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() {
|
export function Navbar() {
|
||||||
const router = useRouter();
|
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() {
|
async function handleLogout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
|
@ -13,40 +26,82 @@ export function Navbar() {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const nav = (
|
||||||
<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">
|
<Link href="/" onClick={() => setOpen(false)} className="mb-9 flex items-center gap-3">
|
||||||
<div className="flex items-center justify-between h-14">
|
<Mark />
|
||||||
<Link
|
<span>
|
||||||
href="/"
|
<span className="block text-[11px] font-bold uppercase tracking-[0.2em] text-text-muted">Your</span>
|
||||||
className="flex items-center gap-2.5 text-text-heading font-semibold hover:text-primary transition-colors"
|
<span className="editorial-title block text-xl leading-none text-text-heading">Study Desk</span>
|
||||||
>
|
</span>
|
||||||
<svg
|
</Link>
|
||||||
className="w-5 h-5 text-primary"
|
|
||||||
fill="none"
|
<nav className="space-y-1" aria-label="Primary navigation">
|
||||||
stroke="currentColor"
|
<Link
|
||||||
viewBox="0 0 24 24"
|
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
|
<span aria-hidden>▤</span> Flashcards
|
||||||
strokeLinecap="round"
|
</Link>
|
||||||
strokeLinejoin="round"
|
<Link
|
||||||
strokeWidth={2}
|
href={`/${classSlug}/quizzes`}
|
||||||
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"
|
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"}`}
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
Sign out
|
<span aria-hidden>✓</span> Quizzes
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</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>
|
</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>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,15 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const typeStr = targetType === "GROUP" ? "groups" : (targetType === "DECK" ? "flashcards" : "quizzes");
|
const typeStr = isGroupShared
|
||||||
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}`;
|
? "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);
|
await navigator.clipboard.writeText(url);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
|
@ -56,7 +63,7 @@ export function ShareMenu({ targetType, contentId, classSlug }: ShareMenuProps)
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
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"
|
title="Share"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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 && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
<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">
|
<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="text-sm font-semibold text-text-heading mb-3">Public Sharing</h4>
|
<h4 className="editorial-title mb-3 text-xl text-text-heading">Public sharing</h4>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
|
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export function ThemeToggle() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
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() {
|
function toggleTheme() {
|
||||||
|
|
@ -30,9 +30,9 @@ export function ThemeToggle() {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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"}
|
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 ? (
|
{isDark ? (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue