feat: UI revamp with sidebar nav and grouped library
Replace top Nav with Sidebar, add lib/group.js for library grouping, tokenized color/alert styles, and tighter Settings/Canvas export layout. Adds @tabler/icons-react. Verified production build (standalone) passes, so the Docker image built from this tree matches the local app. Co-authored-by: Claude <claude-code@anthropic.com>
This commit is contained in:
+93
-89
@@ -1,29 +1,38 @@
|
||||
"use client";
|
||||
// app/library/page.jsx — everything you've made, saved locally in data/db.json.
|
||||
import { useEffect, useState } from "react";
|
||||
// app/library/page.jsx — everything you've made, grouped by subject, saved locally.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IconSearch, IconPlus, IconCopy, IconTrash, IconArrowRight } from "@tabler/icons-react";
|
||||
import { groupBySubject } from "@/lib/group";
|
||||
|
||||
const TYPE_LABELS = {
|
||||
quiz: "Quiz",
|
||||
test: "Test",
|
||||
worksheet: "Worksheet",
|
||||
discussion: "Discussion",
|
||||
case_study: "Case study",
|
||||
quiz: "Quiz", test: "Test", worksheet: "Worksheet", discussion: "Discussion", case_study: "Case study",
|
||||
};
|
||||
const TYPE_CHIP = {
|
||||
quiz: "chip", test: "chip chip-red", worksheet: "chip chip-gold", discussion: "chip chip-neutral", case_study: "chip chip-neutral",
|
||||
};
|
||||
const FILTERS = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "quiz", label: "Quizzes" },
|
||||
{ id: "test", label: "Tests" },
|
||||
{ id: "worksheet", label: "Worksheets" },
|
||||
];
|
||||
|
||||
function SkeletonCard() {
|
||||
function SkeletonGroup() {
|
||||
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 className="lib-group">
|
||||
<div className="skeleton-line short" style={{ height: 22, marginBottom: 14 }} />
|
||||
<div className="panel lib-rows skeleton-card">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="lib-row" style={{ borderTop: i ? "1px solid var(--line)" : "none" }}>
|
||||
<div className="skeleton-chip" style={{ marginBottom: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="skeleton-line medium" style={{ marginBottom: 6 }} />
|
||||
<div className="skeleton-line short" style={{ marginBottom: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -33,6 +42,7 @@ export default function LibraryPage() {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
|
||||
@@ -45,70 +55,53 @@ export default function LibraryPage() {
|
||||
useEffect(load, []);
|
||||
|
||||
async function duplicate(id) {
|
||||
setBusy(id);
|
||||
setError("");
|
||||
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 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("");
|
||||
}
|
||||
} 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("");
|
||||
}
|
||||
try { await fetch("/api/assignments/" + id, { method: "DELETE" }); load(); }
|
||||
finally { setBusy(""); }
|
||||
}
|
||||
|
||||
const filtered = (items || []).filter((a) => {
|
||||
const filtered = useMemo(() => {
|
||||
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 (items || []).filter((a) => {
|
||||
if (filter !== "all" && a.assignmentType !== filter) return false;
|
||||
if (!q) return true;
|
||||
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
|
||||
.filter(Boolean).join(" ").toLowerCase().includes(q);
|
||||
});
|
||||
}, [items, query, filter]);
|
||||
|
||||
const groups = useMemo(() => groupBySubject(filtered), [filtered]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
|
||||
<div className="page page-narrow">
|
||||
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap", marginBottom: 22 }}>
|
||||
<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>
|
||||
<Link href="/" className="btn btn-primary"><IconPlus size={17} /> 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 && (<><SkeletonGroup /><SkeletonGroup /></>)}
|
||||
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="empty">
|
||||
@@ -120,36 +113,50 @@ export default function LibraryPage() {
|
||||
|
||||
{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 className="lib-toolbar">
|
||||
<div className="search-wrap">
|
||||
<IconSearch size={16} />
|
||||
<input type="text" placeholder="Search by title, subject, grade, or type…" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Search library" />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
||||
{FILTERS.map((f) => (
|
||||
<button key={f.id} className={`fchip${filter === f.id ? " active" : ""}`} onClick={() => setFilter(f.id)}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && <p className="muted">No matches{query ? <> for “{query}”</> : ""}.</p>}
|
||||
|
||||
{groups.map((g) => (
|
||||
<section key={g.key} className="lib-group">
|
||||
<div className="lib-group-head">
|
||||
<h2>{g.key}</h2>
|
||||
<span className="chip chip-pill chip-neutral">{g.items.length}</span>
|
||||
</div>
|
||||
<div className="panel lib-rows">
|
||||
{g.items.map((a) => (
|
||||
<div key={a.id} className="lib-row">
|
||||
<span className={TYPE_CHIP[a.assignmentType] || "chip chip-neutral"}>{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Link href={"/editor/" + a.id} className="lib-row-title" style={{ display: "block" }}>{a.title}</Link>
|
||||
<p className="lib-meta">
|
||||
{[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}
|
||||
{(a.gradeLevel || a.subject) ? " · " : ""}
|
||||
{a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="lib-actions">
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}><IconArrowRight size={14} /> Open</button>
|
||||
<button className="icon-btn" title="Duplicate" aria-label="Duplicate" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
|
||||
{busy === a.id ? <span className="spinner" /> : <IconCopy size={16} />}
|
||||
</button>
|
||||
<button className="icon-btn danger" title="Delete" aria-label="Delete" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -157,9 +164,6 @@ export default function LibraryPage() {
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
try { return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user