Self-contained Dockerized build for end users. Run via docker compose; see README.md for setup. Source-only, no sample data or build artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
5.8 KiB
JavaScript
166 lines
5.8 KiB
JavaScript
"use client";
|
|
// app/library/page.jsx — everything you've made, saved locally in data/db.json.
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
const TYPE_LABELS = {
|
|
quiz: "Quiz",
|
|
test: "Test",
|
|
worksheet: "Worksheet",
|
|
discussion: "Discussion",
|
|
case_study: "Case study",
|
|
};
|
|
|
|
function SkeletonCard() {
|
|
return (
|
|
<div className="card skeleton-card" aria-hidden="true">
|
|
<div className="skeleton-chip" />
|
|
<div className="skeleton-line medium" />
|
|
<div className="skeleton-line short" style={{ marginBottom: 18 }} />
|
|
<div className="skeleton-line full" />
|
|
<div className="skeleton-line" style={{ width: "40%", marginBottom: 16 }} />
|
|
<div style={{ display: "flex", gap: 7 }}>
|
|
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
|
|
<div className="skeleton-line" style={{ width: 80, height: 30, borderRadius: 7, marginBottom: 0 }} />
|
|
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function LibraryPage() {
|
|
const router = useRouter();
|
|
const [items, setItems] = useState(null);
|
|
const [query, setQuery] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState("");
|
|
|
|
function load() {
|
|
fetch("/api/assignments")
|
|
.then((r) => r.json())
|
|
.then((d) => setItems(d.assignments || []))
|
|
.catch(() => setError("Could not load your library."));
|
|
}
|
|
useEffect(load, []);
|
|
|
|
async function duplicate(id) {
|
|
setBusy(id);
|
|
setError("");
|
|
try {
|
|
const res = await fetch("/api/assignments/" + id);
|
|
const full = await res.json();
|
|
if (!res.ok) throw new Error(full.error || "Could not load that assignment.");
|
|
const { id: _id, createdAt, updatedAt, ...copy } = full;
|
|
copy.title = (copy.title || "Untitled") + " (copy)";
|
|
const res2 = await fetch("/api/assignments", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(copy),
|
|
});
|
|
const created = await res2.json();
|
|
if (!res2.ok) throw new Error(created.error || "Could not duplicate.");
|
|
load();
|
|
} catch (e) {
|
|
setError(String(e.message || e));
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function remove(id, title) {
|
|
if (!confirm(`Delete "${title}"? This can't be undone.`)) return;
|
|
setBusy(id);
|
|
try {
|
|
await fetch("/api/assignments/" + id, { method: "DELETE" });
|
|
load();
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
const filtered = (items || []).filter((a) => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(q);
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-head" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
|
|
<div style={{ flex: 1 }}>
|
|
<h1>Library</h1>
|
|
<p>Everything you’ve created, stored locally on this computer.</p>
|
|
</div>
|
|
<Link href="/" className="btn btn-primary">✎ New assignment</Link>
|
|
</div>
|
|
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
|
|
{/* Skeleton loading state */}
|
|
{items === null && (
|
|
<div className="lib-grid">
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
</div>
|
|
)}
|
|
|
|
{items !== null && items.length === 0 && (
|
|
<div className="empty">
|
|
<h3>Nothing here yet</h3>
|
|
<p>Create your first assignment and it will be saved here automatically.</p>
|
|
<Link href="/" className="btn btn-primary" style={{ marginTop: 12 }}>Create an assignment</Link>
|
|
</div>
|
|
)}
|
|
|
|
{items !== null && items.length > 0 && (
|
|
<>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by title, subject, grade, or type…"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
style={{ marginBottom: 18, maxWidth: 440 }}
|
|
aria-label="Search library"
|
|
/>
|
|
{filtered.length === 0 && <p className="muted">No matches for “{query}”.</p>}
|
|
<div className="lib-grid">
|
|
{filtered.map((a) => (
|
|
<div key={a.id} className="card lib-card card-lift">
|
|
<span className="chip">{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span>
|
|
<h3 style={{ marginTop: 10 }}>
|
|
<Link href={"/editor/" + a.id} style={{ color: "inherit" }}>{a.title}</Link>
|
|
</h3>
|
|
<p className="lib-meta">
|
|
{[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}<br />
|
|
{a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)}
|
|
</p>
|
|
<div className="lib-actions">
|
|
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}>Open</button>
|
|
<button className="btn btn-sm" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
|
|
{busy === a.id ? <span className="spinner" /> : "Duplicate"}
|
|
</button>
|
|
<button className="btn btn-sm btn-danger" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}>Delete</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
try {
|
|
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|