Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s
This commit is contained in:
parent
666ceb7325
commit
b7ce314f01
105 changed files with 35510 additions and 11 deletions
173
src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx
Normal file
173
src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||
import { CardManager } from "@/components/flashcards/CardManager";
|
||||
import { ShareMenu } from "@/components/ui/ShareMenu";
|
||||
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
front: string;
|
||||
back: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface DeckData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
cards: Card[];
|
||||
class: { slug: string; name: string };
|
||||
progress: Array<{
|
||||
mode: "SEQUENTIAL" | "SHUFFLED";
|
||||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function DeckStudyPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const classSlug = params.classSlug as string;
|
||||
const deckId = params.deckId as string;
|
||||
const [deck, setDeck] = useState<DeckData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<"study" | "manage">("study");
|
||||
const [restartKey, setRestartKey] = useState(0);
|
||||
|
||||
async function handleRestart() {
|
||||
if (!deck) return;
|
||||
|
||||
// Clear progress in database for both modes
|
||||
await Promise.all([
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" })
|
||||
}).catch(() => {}),
|
||||
fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" })
|
||||
}).catch(() => {})
|
||||
]);
|
||||
|
||||
// Clear locally and force remount
|
||||
setDeck({ ...deck, progress: [] });
|
||||
setRestartKey(k => k + 1);
|
||||
}
|
||||
|
||||
const fetchDeck = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/decks/${deckId}`);
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
const data = await res.json();
|
||||
setDeck(data);
|
||||
} catch {
|
||||
router.push(`/${classSlug}/flashcards`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [deckId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeck();
|
||||
}, [fetchDeck]);
|
||||
|
||||
if (loading || !deck) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="animate-subtle-pulse">
|
||||
<div className="h-8 bg-bg-surface-alt rounded w-1/3 mb-4" />
|
||||
<div className="h-64 bg-bg-surface rounded-xl border border-border-light" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push(`/${classSlug}/flashcards`)}
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to decks
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-text-heading">{deck.name}</h1>
|
||||
{deck.description && (
|
||||
<p className="text-sm text-text-secondary mt-1">{deck.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle & Share */}
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
{view === "study" && (
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
||||
title="Restart Session"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<ShareMenu targetType="DECK" contentId={deck.id} classSlug={classSlug} />
|
||||
|
||||
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
|
||||
<button
|
||||
onClick={() => setView("study")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
view === "study"
|
||||
? "bg-bg-surface text-text-heading shadow-sm"
|
||||
: "text-text-muted hover:text-text-heading"
|
||||
}`}
|
||||
>
|
||||
Study
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("manage")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
view === "manage"
|
||||
? "bg-bg-surface text-text-heading shadow-sm"
|
||||
: "text-text-muted hover:text-text-heading"
|
||||
}`}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{view === "study" ? (
|
||||
<FlashcardViewer
|
||||
key={restartKey}
|
||||
cards={deck.cards}
|
||||
deckId={deck.id}
|
||||
initialProgress={
|
||||
deck.progress?.length
|
||||
? deck.progress.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CardManager
|
||||
cards={deck.cards}
|
||||
deckId={deck.id}
|
||||
onCardsChanged={fetchDeck}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
src/app/(protected)/[classSlug]/flashcards/page.tsx
Normal file
253
src/app/(protected)/[classSlug]/flashcards/page.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
||||
interface DeckItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
_count: { cards: number };
|
||||
progress: Array<{
|
||||
mode: string;
|
||||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [decks, setDecks] = useState<DeckItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const fetchDecks = useCallback(async (cId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/decks/list?classId=${cId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDecks(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch {
|
||||
setDecks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const classRes = await fetch("/api/classes");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setClassId(cls.id);
|
||||
fetchDecks(cls.id);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [classSlug, fetchDecks]);
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its cards?`)) return;
|
||||
await fetch(`/api/decks/${id}`, { method: "DELETE" });
|
||||
setDecks((prev) => prev.filter((d) => d.id !== id));
|
||||
}
|
||||
|
||||
async function handleRename(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
await fetch(`/api/decks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim() }),
|
||||
});
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, name: editName.trim() } : d))
|
||||
);
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
function getProgressLabel(deck: DeckItem) {
|
||||
if (!deck.progress?.length) return null;
|
||||
const prog = deck.progress[0];
|
||||
const order = JSON.parse(prog.orderJson) as string[];
|
||||
const results = prog.cardResultsJson
|
||||
? (JSON.parse(prog.cardResultsJson) as Record<string, string>)
|
||||
: {};
|
||||
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||
const total = order.length;
|
||||
const current = Math.min(prog.currentIndex + 1, total);
|
||||
const modeLabel = prog.mode === "SHUFFLED" ? "shuffled" : "sequential";
|
||||
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-heading">Flashcard Decks</h2>
|
||||
<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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Import Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2].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>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && decks.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No flashcard decks yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first deck to start studying</p>
|
||||
<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 cursor-pointer"
|
||||
>
|
||||
Import Deck
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deck grid */}
|
||||
{!loading && decks.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{decks.map((deck) => {
|
||||
const progressLabel = getProgressLabel(deck);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deck.id}
|
||||
className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Name (editable) */}
|
||||
{editingId === deck.id ? (
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename(deck.id)}
|
||||
autoFocus
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleRename(deck.id)}
|
||||
className="text-sm text-primary hover:text-primary-hover cursor-pointer"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-sm text-text-muted hover:text-text-heading cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">
|
||||
{deck.name}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{deck.description && (
|
||||
<p className="text-sm text-text-secondary mb-3 line-clamp-2">{deck.description}</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
|
||||
{deck._count.cards} {deck._count.cards === 1 ? "card" : "cards"}
|
||||
</span>
|
||||
{progressLabel && (
|
||||
<span className="text-xs text-text-muted">{progressLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light">
|
||||
<Link
|
||||
href={`/${classSlug}/flashcards/${deck.id}`}
|
||||
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
|
||||
>
|
||||
{deck.progress?.length ? "Continue" : "Study"}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(deck.id);
|
||||
setEditName(deck.name);
|
||||
}}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
||||
title="Rename"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deck.id, deck.name)}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer"
|
||||
title="Delete"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImport && (
|
||||
<ImportModal
|
||||
classId={classId}
|
||||
importType="flashcards"
|
||||
onClose={() => setShowImport(false)}
|
||||
onImported={() => {
|
||||
setShowImport(false);
|
||||
setLoading(true);
|
||||
// Re-fetch
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/app/(protected)/[classSlug]/layout.tsx
Normal file
22
src/app/(protected)/[classSlug]/layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { getClassBySlug } from "@/services/classService";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ClassHeader } from "@/components/ui/ClassHeader";
|
||||
|
||||
export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
|
||||
const { classSlug } = await props.params;
|
||||
const classData = await getClassBySlug(classSlug);
|
||||
|
||||
if (!classData) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Dynamic Header & Tabs */}
|
||||
<ClassHeader classSlug={classSlug} className={classData.name} />
|
||||
|
||||
{/* Page content */}
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/app/(protected)/[classSlug]/page.tsx
Normal file
6
src/app/(protected)/[classSlug]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ClassPage(props: PageProps<"/[classSlug]">) {
|
||||
const { classSlug } = await props.params;
|
||||
redirect(`/${classSlug}/flashcards`);
|
||||
}
|
||||
177
src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx
Normal file
177
src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||
import { AttemptHistory } from "@/components/quizzes/AttemptHistory";
|
||||
import { ShareMenu } from "@/components/ui/ShareMenu";
|
||||
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
type: string;
|
||||
prompt: string;
|
||||
rationale: string;
|
||||
category: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
interface QuizAttempt {
|
||||
id: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
answersJson: string;
|
||||
isPartialRetake: boolean;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
interface QuizData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
questions: Question[];
|
||||
class: { slug: string; name: string };
|
||||
progress: Array<{
|
||||
mode: string;
|
||||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function QuizStudyPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const classSlug = params.classSlug as string;
|
||||
const quizId = params.quizId as string;
|
||||
|
||||
const [quiz, setQuiz] = useState<QuizData | null>(null);
|
||||
const [attempts, setAttempts] = useState<QuizAttempt[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<"history" | "take">("take");
|
||||
const [retakeIds, setRetakeIds] = useState<string[] | null>(null);
|
||||
|
||||
const fetchQuizAndAttempts = useCallback(async () => {
|
||||
try {
|
||||
const [quizRes, attemptsRes] = await Promise.all([
|
||||
fetch(`/api/quizzes/${quizId}`),
|
||||
fetch(`/api/quizzes/${quizId}/attempt`)
|
||||
]);
|
||||
|
||||
if (!quizRes.ok) throw new Error("Not found");
|
||||
|
||||
const [quizData, attemptsData] = await Promise.all([
|
||||
quizRes.json(),
|
||||
attemptsRes.ok ? attemptsRes.json() : []
|
||||
]);
|
||||
|
||||
setQuiz(quizData);
|
||||
setAttempts(attemptsData);
|
||||
|
||||
// Optional: keep track of attempt count, but default view is already "take"
|
||||
if (attemptsData.length > 0) {
|
||||
// We can do something here if we wanted to auto-switch to history, but user wants take
|
||||
}
|
||||
} catch {
|
||||
router.push(`/${classSlug}/quizzes`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [quizId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuizAndAttempts();
|
||||
}, [fetchQuizAndAttempts]);
|
||||
|
||||
if (loading || !quiz) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="animate-subtle-pulse">
|
||||
<div className="h-8 bg-bg-surface-alt rounded w-1/3 mb-4" />
|
||||
<div className="h-64 bg-bg-surface rounded-xl border border-border-light" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full py-2 md:py-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push(`/${classSlug}/quizzes`)}
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-text-heading mb-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to quizzes
|
||||
</button>
|
||||
<h1 className="text-xl font-bold text-text-heading">{quiz.name}</h1>
|
||||
{quiz.description && (
|
||||
<p className="text-sm text-text-secondary mt-1">{quiz.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View toggle & Share */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ShareMenu targetType="QUIZ" contentId={quiz.id} classSlug={classSlug} />
|
||||
|
||||
{attempts.length > 0 && (
|
||||
<div className="flex bg-bg-surface-alt rounded-lg p-0.5 border border-border-light">
|
||||
<button
|
||||
onClick={() => setView("history")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
view === "history"
|
||||
? "bg-bg-surface text-text-heading shadow-sm"
|
||||
: "text-text-muted hover:text-text-heading"
|
||||
}`}
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("take")}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 cursor-pointer ${
|
||||
view === "take"
|
||||
? "bg-bg-surface text-text-heading shadow-sm"
|
||||
: "text-text-muted hover:text-text-heading"
|
||||
}`}
|
||||
>
|
||||
Take Quiz
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{view === "history" && attempts.length > 0 ? (
|
||||
<AttemptHistory
|
||||
quiz={quiz}
|
||||
attempts={attempts}
|
||||
onRetake={(missedIds) => {
|
||||
setRetakeIds(missedIds);
|
||||
setView("take");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<QuizViewer
|
||||
quiz={quiz}
|
||||
retakeIds={retakeIds}
|
||||
onFinished={() => {
|
||||
setRetakeIds(null);
|
||||
fetchQuizAndAttempts();
|
||||
setView("history");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/app/(protected)/[classSlug]/quizzes/page.tsx
Normal file
174
src/app/(protected)/[classSlug]/quizzes/page.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
||||
interface QuizItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
_count: { questions: number; attempts: number };
|
||||
}
|
||||
|
||||
export default function QuizzesPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
|
||||
const fetchQuizzes = useCallback(async (cId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/quizzes/list?classId=${cId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuizzes(Array.isArray(data) ? data : []);
|
||||
}
|
||||
} catch {
|
||||
setQuizzes([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const classRes = await fetch("/api/classes");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setClassId(cls.id);
|
||||
fetchQuizzes(cls.id);
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [classSlug, fetchQuizzes]);
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its questions and attempts?`)) return;
|
||||
await fetch(`/api/quizzes/${id}`, { method: "DELETE" });
|
||||
setQuizzes((prev) => prev.filter((q) => q.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-text-heading">Quizzes</h2>
|
||||
<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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Import Quiz
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2].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>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && quizzes.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first quiz to start practicing</p>
|
||||
<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 cursor-pointer"
|
||||
>
|
||||
Import Quiz
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quiz grid */}
|
||||
{!loading && quizzes.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{quizzes.map((quiz) => (
|
||||
<div
|
||||
key={quiz.id}
|
||||
className="group bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||
>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">
|
||||
{quiz.name}
|
||||
</h3>
|
||||
{quiz.description && (
|
||||
<p className="text-sm text-text-secondary mb-3 line-clamp-2">
|
||||
{quiz.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium">
|
||||
{quiz._count.questions} {quiz._count.questions === 1 ? "question" : "questions"}
|
||||
</span>
|
||||
{quiz._count.attempts > 0 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{quiz._count.attempts} {quiz._count.attempts === 1 ? "attempt" : "attempts"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-border-light">
|
||||
<Link
|
||||
href={`/${classSlug}/quizzes/${quiz.id}`}
|
||||
className="flex-1 text-center py-2 px-4 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200"
|
||||
>
|
||||
Start Quiz
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(quiz.id, quiz.name)}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer"
|
||||
title="Delete"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImport && (
|
||||
<ImportModal
|
||||
classId={classId}
|
||||
importType="quizzes"
|
||||
onClose={() => setShowImport(false)}
|
||||
onImported={() => {
|
||||
setShowImport(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(protected)/layout.tsx
Normal file
21
src/app/(protected)/layout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { isAuthenticated } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/ui/Navbar";
|
||||
|
||||
export default async function ProtectedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const authed = await isAuthenticated();
|
||||
if (!authed) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/app/(protected)/page.tsx
Normal file
208
src/app/(protected)/page.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ClassItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
_count: { decks: number; quizSets: number };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClasses();
|
||||
}, []);
|
||||
|
||||
async function fetchClasses() {
|
||||
try {
|
||||
const res = await fetch("/api/classes");
|
||||
const data = await res.json();
|
||||
setClasses(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNewName("");
|
||||
setShowCreate(false);
|
||||
fetchClasses();
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||
|
||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||
fetchClasses();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-heading">Your Classes</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Select a class to study flashcards or take quizzes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 shadow-sm cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Class
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create class inline form */}
|
||||
{showCreate && (
|
||||
<div className="bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] p-5 mb-6 animate-slide-up">
|
||||
<form onSubmit={handleCreate} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. PHA 109, Anatomy, Nursing Fundamentals..."
|
||||
autoFocus
|
||||
className="flex-1 px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newName.trim()}
|
||||
className="px-5 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover disabled:opacity-50 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewName("");
|
||||
}}
|
||||
className="px-4 py-2.5 rounded-lg border border-border text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-bg-surface rounded-xl border border-border-light p-6 animate-subtle-pulse"
|
||||
>
|
||||
<div className="h-5 bg-bg-surface-alt rounded w-2/3 mb-3" />
|
||||
<div className="h-4 bg-bg-surface-alt rounded w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && classes.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-bg-callout mb-4">
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-text-heading mb-1">
|
||||
No classes yet
|
||||
</h2>
|
||||
<p className="text-text-secondary mb-4">
|
||||
Create your first class to start studying
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Your First Class
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Class grid */}
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{classes.map((cls) => (
|
||||
<div
|
||||
key={cls.id}
|
||||
className="group relative bg-bg-surface rounded-xl border border-border-light shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||
>
|
||||
<Link
|
||||
href={`/${cls.slug}`}
|
||||
className="block p-6"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(cls.id, cls.name);
|
||||
}}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-error-bg opacity-0 group-hover:opacity-100 transition-all duration-200 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/app/api/auth/login/route.ts
Normal file
105
src/app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
|
||||
// Progressive lockout thresholds from Section 4
|
||||
function getLockoutDuration(failedAttempts: number): number {
|
||||
if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours
|
||||
if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes
|
||||
if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes
|
||||
if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limit by IP
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip = forwarded?.split(",")[0]?.trim() ?? "unknown";
|
||||
const rateCheck = checkRateLimit(ip);
|
||||
|
||||
if (!rateCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Try again shortly." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.password || typeof body.password !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Password is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check lockout state
|
||||
let security = await prisma.authSecurity.findUnique({ where: { id: 1 } });
|
||||
if (!security) {
|
||||
security = await prisma.authSecurity.create({ data: { id: 1 } });
|
||||
}
|
||||
|
||||
if (security.lockedUntil && security.lockedUntil > new Date()) {
|
||||
const remainingMs =
|
||||
security.lockedUntil.getTime() - Date.now();
|
||||
const remainingMin = Math.ceil(remainingMs / 60000);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`,
|
||||
},
|
||||
{ status: 423 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordHash = process.env.ADMIN_PASSWORD_HASH;
|
||||
|
||||
// For development: if no hash is set, accept any non-empty password
|
||||
let isValid = false;
|
||||
if (!passwordHash) {
|
||||
isValid = body.password.length > 0;
|
||||
} else {
|
||||
// Dynamic import to avoid issues when argon2 isn't installed
|
||||
try {
|
||||
const argon2 = await import("argon2");
|
||||
isValid = await argon2.verify(passwordHash, body.password);
|
||||
} catch {
|
||||
// Fallback: direct comparison (only for development)
|
||||
isValid = body.password === passwordHash;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
const newFailedAttempts = security.failedAttempts + 1;
|
||||
const lockoutMs = getLockoutDuration(newFailedAttempts);
|
||||
const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null;
|
||||
|
||||
await prisma.authSecurity.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
failedAttempts: newFailedAttempts,
|
||||
lockedUntil,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid password" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Success — reset lockout, create session
|
||||
await prisma.authSecurity.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
failedAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await createSession();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
7
src/app/api/auth/logout/route.ts
Normal file
7
src/app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { destroySession } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
await destroySession();
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
34
src/app/api/cards/[id]/route.ts
Normal file
34
src/app/api/cards/[id]/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as cardService from "@/services/cardService";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/cards/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await cardService.updateCard(id, {
|
||||
front: body?.front?.trim(),
|
||||
back: body?.back?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/cards/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await cardService.deleteCard(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/classes/[id]/route.ts
Normal file
37
src/app/api/classes/[id]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as classService from "@/services/classService";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/classes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || (!body.name && body.name !== "")) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await classService.updateClass(id, {
|
||||
name: body.name?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/classes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await classService.deleteClass(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
20
src/app/api/classes/route.ts
Normal file
20
src/app/api/classes/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as classService from "@/services/classService";
|
||||
|
||||
export async function GET() {
|
||||
const classes = await classService.listClasses();
|
||||
return NextResponse.json(classes);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Class name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const newClass = await classService.createClass(body.name.trim());
|
||||
return NextResponse.json(newClass, { status: 201 });
|
||||
}
|
||||
29
src/app/api/decks/[id]/cards/route.ts
Normal file
29
src/app/api/decks/[id]/cards/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as cardService from "@/services/cardService";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/decks/[id]/cards">
|
||||
) {
|
||||
const { id: deckId } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body?.front ||
|
||||
!body?.back ||
|
||||
typeof body.front !== "string" ||
|
||||
typeof body.back !== "string"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "front and back are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const card = await cardService.createCard(deckId, {
|
||||
front: body.front.trim(),
|
||||
back: body.back.trim(),
|
||||
});
|
||||
|
||||
return NextResponse.json(card, { status: 201 });
|
||||
}
|
||||
48
src/app/api/decks/[id]/route.ts
Normal file
48
src/app/api/decks/[id]/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/decks/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const deck = await deckService.getDeckWithCards(id);
|
||||
|
||||
if (!deck) {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(deck);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/decks/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await deckService.updateDeck(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/decks/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await deckService.deleteDeck(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/decks/list/route.ts
Normal file
14
src/app/api/decks/list/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classId = searchParams.get("classId");
|
||||
|
||||
if (!classId) {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const decks = await deckService.listDecksByClass(classId);
|
||||
return NextResponse.json(decks);
|
||||
}
|
||||
34
src/app/api/decks/route.ts
Normal file
34
src/app/api/decks/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { classId, data, name } = body;
|
||||
|
||||
if (!classId || typeof classId !== "string") {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side validation — never trust client
|
||||
const parsed = flashcardImportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const deck = await deckService.createDeckFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined
|
||||
);
|
||||
|
||||
return NextResponse.json(deck, { status: 201 });
|
||||
}
|
||||
66
src/app/api/progress/route.ts
Normal file
66
src/app/api/progress/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as progressService from "@/services/progressService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ";
|
||||
const contentId = searchParams.get("contentId");
|
||||
const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED";
|
||||
|
||||
if (!contentType || !contentId || !mode) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const progress = await progressService.getProgress(
|
||||
contentType,
|
||||
contentId,
|
||||
mode
|
||||
);
|
||||
|
||||
return NextResponse.json(progress);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const progress = await progressService.upsertProgress({
|
||||
contentType: body.contentType,
|
||||
contentId: body.contentId,
|
||||
mode: body.mode,
|
||||
currentIndex: body.currentIndex ?? 0,
|
||||
orderJson: body.orderJson,
|
||||
answersJson: body.answersJson,
|
||||
cardResultsJson: body.cardResultsJson,
|
||||
});
|
||||
|
||||
return NextResponse.json(progress);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await progressService.clearProgress(
|
||||
body.contentType,
|
||||
body.contentId,
|
||||
body.mode
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
72
src/app/api/quizzes/[id]/attempt/route.ts
Normal file
72
src/app/api/quizzes/[id]/attempt/route.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
|
||||
import { scoreQuiz } from "@/lib/scoring";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || !body.answersJson) {
|
||||
return NextResponse.json(
|
||||
{ error: "answersJson is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const quizSet = await getQuizSetWithQuestions(id);
|
||||
if (!quizSet) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse answers
|
||||
let answers: Record<string, string[]>;
|
||||
try {
|
||||
answers = JSON.parse(body.answersJson);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Determine which questions were included in this attempt
|
||||
const isPartialRetake = body.isPartialRetake === true;
|
||||
|
||||
// If partial retake, we only score the questions that actually had answers provided
|
||||
// or that were explicitly passed in a questionIds array.
|
||||
// For simplicity, we filter the quizSet questions down to what's in the answers object
|
||||
// if it's a partial retake, though the client will only show those anyway.
|
||||
let questionsToScore = quizSet.questions;
|
||||
if (isPartialRetake) {
|
||||
const answeredIds = Object.keys(answers);
|
||||
questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id));
|
||||
}
|
||||
|
||||
// Format questions for the scoring utility
|
||||
const formattedQuestions = questionsToScore.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })),
|
||||
}));
|
||||
|
||||
const { total, maxScore } = scoreQuiz(formattedQuestions, answers);
|
||||
|
||||
const attempt = await createQuizAttempt({
|
||||
quizSetId: id,
|
||||
score: total,
|
||||
maxScore: maxScore,
|
||||
answersJson: body.answersJson,
|
||||
isPartialRetake,
|
||||
});
|
||||
|
||||
return NextResponse.json(attempt, { status: 201 });
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const attempts = await listQuizAttempts(id);
|
||||
return NextResponse.json(attempts);
|
||||
}
|
||||
48
src/app/api/quizzes/[id]/route.ts
Normal file
48
src/app/api/quizzes/[id]/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const quizSet = await quizService.getQuizSetWithQuestions(id);
|
||||
|
||||
if (!quizSet) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(quizSet);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const updated = await quizService.updateQuizSet(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
|
||||
try {
|
||||
await quizService.deleteQuizSet(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/quizzes/list/route.ts
Normal file
14
src/app/api/quizzes/list/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const classId = searchParams.get("classId");
|
||||
|
||||
if (!classId) {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const quizSets = await quizService.listQuizSetsByClass(classId);
|
||||
return NextResponse.json(quizSets);
|
||||
}
|
||||
34
src/app/api/quizzes/route.ts
Normal file
34
src/app/api/quizzes/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
import { quizImportSchema } from "@/lib/validation/importSchemas";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { classId, data, name } = body;
|
||||
|
||||
if (!classId || typeof classId !== "string") {
|
||||
return NextResponse.json({ error: "classId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Server-side validation
|
||||
const parsed = quizImportSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const quizSet = await quizService.createQuizSetFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined
|
||||
);
|
||||
|
||||
return NextResponse.json(quizSet, { status: 201 });
|
||||
}
|
||||
31
src/app/api/settings/llm-instructions/route.ts
Normal file
31
src/app/api/settings/llm-instructions/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as settingsService from "@/services/settingsService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const instructions = await settingsService.getLlmInstructions(type);
|
||||
return NextResponse.json({ value: instructions });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards";
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.value !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "value is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (body.value === "__RESET__") {
|
||||
await settingsService.resetLlmInstructions(type);
|
||||
const instructions = await settingsService.getLlmInstructions(type);
|
||||
return NextResponse.json({ value: instructions });
|
||||
}
|
||||
|
||||
await settingsService.updateLlmInstructions(type, body.value);
|
||||
return NextResponse.json({ value: body.value });
|
||||
}
|
||||
32
src/app/api/share/route.ts
Normal file
32
src/app/api/share/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toggleShareLink, getShareLinkForContent } from "@/services/shareService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ";
|
||||
const contentId = searchParams.get("contentId");
|
||||
|
||||
if (!targetType || !contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "targetType and contentId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const link = await getShareLinkForContent(targetType, contentId);
|
||||
return NextResponse.json({ token: link?.id || null });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.targetType || !body?.contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "targetType and contentId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const link = await toggleShareLink(body.targetType, body.contentId);
|
||||
return NextResponse.json({ token: link?.id || null });
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
141
src/app/globals.css
Normal file
141
src/app/globals.css
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
/* ── Sage / Teal palette from reference image ── */
|
||||
--color-bg-base: #f0f4f2;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-surface-alt: #f4f7f5;
|
||||
--color-bg-callout: #e8f4f0;
|
||||
--color-primary: #2a7d7d;
|
||||
--color-primary-hover: #1f6363;
|
||||
--color-primary-light: #3a9e9e;
|
||||
--color-text-heading: #1a2f2f;
|
||||
--color-text-body: #2d4a4a;
|
||||
--color-text-secondary: #4a6a6a;
|
||||
--color-text-muted: #7a9a9a;
|
||||
--color-border: #c8d8d0;
|
||||
--color-border-light: #dce8e2;
|
||||
--color-success: #2d8a4e;
|
||||
--color-success-bg: #e8f5ed;
|
||||
--color-error: #c44c4c;
|
||||
--color-error-bg: #fce8e8;
|
||||
--color-badge-bg: #e0eeea;
|
||||
--color-badge-text: #2a6a5a;
|
||||
--color-danger: #dc3545;
|
||||
--color-danger-hover: #c82333;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Animations */
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* ── Base Styles ── */
|
||||
body {
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-body);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Scrollbar Styling ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Card flip animation ── */
|
||||
.perspective-1000 {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.preserve-3d {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.backface-hidden {
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* ── Swipe animations ── */
|
||||
@keyframes swipeLeft {
|
||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateX(-40px) rotate(-8deg); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes swipeRight {
|
||||
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateX(40px) rotate(8deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-swipe-left {
|
||||
animation: swipeLeft 0.4s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
.animate-swipe-right {
|
||||
animation: swipeRight 0.4s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
/* ── Modal animation ── */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s var(--ease-spring);
|
||||
}
|
||||
|
||||
/* ── Subtle pulse for loading states ── */
|
||||
@keyframes subtlePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.animate-subtle-pulse {
|
||||
animation: subtlePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Markdown content styles ── */
|
||||
.markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
|
||||
.markdown-content h3 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--color-text-heading); }
|
||||
.markdown-content p { margin-bottom: 0.75rem; line-height: 1.7; }
|
||||
.markdown-content ul { list-style: disc; padding-left: 1.5rem; margin-bottom: 0.75rem; }
|
||||
.markdown-content ol { list-style: decimal; padding-left: 1.5rem; margin-bottom: 0.75rem; }
|
||||
.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; }
|
||||
28
src/app/layout.tsx
Normal file
28
src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Study App",
|
||||
description: "Self-hosted study application with flashcards and quizzes",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} h-full`} suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
143
src/app/login/page.tsx
Normal file
143
src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo / Title area */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-heading">Study App</h1>
|
||||
<p className="text-text-muted mt-1 text-sm">
|
||||
Enter your password to continue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className="bg-bg-surface rounded-xl shadow-[var(--shadow-card)] border border-border-light p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-text-secondary mb-1.5"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoFocus
|
||||
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-error bg-error-bg rounded-lg px-3 py-2.5">
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Signing in...
|
||||
</span>
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/app/shared/[classSlug]/[type]/[token]/page.tsx
Normal file
101
src/app/shared/[classSlug]/[type]/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { getShareLink } from "@/services/shareService";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||
import Link from "next/link";
|
||||
|
||||
interface SharedPageProps {
|
||||
params: Promise<{
|
||||
classSlug: string;
|
||||
type: string;
|
||||
token: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function SharedPage(props: SharedPageProps) {
|
||||
const { classSlug, type, token } = await props.params;
|
||||
|
||||
if (type !== "flashcards" && type !== "quizzes") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const link = await getShareLink(token);
|
||||
|
||||
if (!link) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Validate that the link matches the URL structure
|
||||
if (type === "flashcards" && !link.deck) notFound();
|
||||
if (type === "quizzes" && !link.quizSet) notFound();
|
||||
|
||||
// Validate class slug matches
|
||||
const targetClassSlug = link.deck ? link.deck.class.slug : link.quizSet?.class.slug;
|
||||
if (targetClassSlug !== classSlug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||
{/* Read-only Header */}
|
||||
<header className="bg-bg-surface border-b border-border-light shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<div className="flex items-center gap-2.5 text-text-heading font-semibold">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
Shared {type === "flashcards" ? "Deck" : "Quiz"}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium px-3 py-1 bg-badge-bg text-badge-text rounded-full">
|
||||
Read Only
|
||||
</span>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-text-heading mb-2">
|
||||
{link.deck ? link.deck.name : link.quizSet?.name}
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
From class: <span className="font-medium text-text-heading">{link.deck ? link.deck.class.name : link.quizSet?.class.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{type === "flashcards" && link.deck && (
|
||||
<FlashcardViewer cards={link.deck.cards} deckId={link.deck.id} isShared={true} />
|
||||
)}
|
||||
|
||||
{type === "quizzes" && link.quizSet && (
|
||||
<QuizViewer
|
||||
quiz={{
|
||||
...link.quizSet,
|
||||
progress: [], // Start fresh since it's shared
|
||||
}}
|
||||
retakeIds={null}
|
||||
isShared={true}
|
||||
onFinished={() => {
|
||||
// In shared mode, finishes local results display, when closed we can reload
|
||||
// but QuizViewer doesn't handle the restart well in shared mode if we just close.
|
||||
// We'll let it stay on results or the user can refresh.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue