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:
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
// components/Sidebar.jsx — the Open Workspace shell: brand, primary nav, a live
|
||||
// library folder tree grouped by subject, and a pinned theme toggle + New button.
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
IconPencil, IconPlus, IconSettings, IconMoon, IconSun,
|
||||
IconChevronDown, IconChevronRight, IconFileText, IconPencilPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { groupBySubject } from "@/lib/group";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Create", icon: IconPlus },
|
||||
{ href: "/settings", label: "Settings", icon: IconSettings },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [theme, setTheme] = useState(null);
|
||||
const [items, setItems] = useState([]);
|
||||
const [collapsed, setCollapsed] = useState({}); // subject -> true when collapsed
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetch("/api/assignments")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setItems(d.assignments || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Reload the tree whenever the route changes (covers create / delete / rename).
|
||||
useEffect(() => { load(); }, [load, pathname]);
|
||||
|
||||
function toggleTheme() {
|
||||
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
if (next === "dark") document.documentElement.dataset.theme = "dark";
|
||||
else delete document.documentElement.dataset.theme;
|
||||
try { localStorage.setItem("theme", next); } catch {}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
const groups = useMemo(() => groupBySubject(items), [items]);
|
||||
const activeId = pathname.startsWith("/editor/") ? decodeURIComponent(pathname.split("/editor/")[1] || "") : "";
|
||||
|
||||
const isNavActive = (href) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
|
||||
|
||||
return (
|
||||
<aside className="sidebar" aria-label="Sidebar">
|
||||
<Link href="/" className="sidebar-brand">
|
||||
<span className="brand-mark" aria-hidden="true"><IconPencil size={19} stroke={2} /></span>
|
||||
<span>
|
||||
<span className="brand-name" style={{ display: "block" }}>Mr. Drew’s</span>
|
||||
<span className="brand-sub">Assignment Creator</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Main">
|
||||
{NAV.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href} className={`navrow${isNavActive(href) ? " active" : ""}`}>
|
||||
<Icon size={19} stroke={2} />
|
||||
<span className="navrow-label">{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-scroll">
|
||||
<div className="sidebar-label">
|
||||
<span>Library</span>
|
||||
<Link href="/library" className="navrow-label" style={{ fontSize: "0.66rem", color: "var(--board-deep)", fontWeight: 700 }}>
|
||||
All
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && (
|
||||
<p className="faint" style={{ fontSize: "0.8rem", padding: "4px 10px", margin: 0 }}>
|
||||
No assignments yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{groups.map((g) => {
|
||||
const open = !collapsed[g.key];
|
||||
return (
|
||||
<div key={g.key} className="tree-group">
|
||||
<button
|
||||
className="tree-folder"
|
||||
onClick={() => setCollapsed((c) => ({ ...c, [g.key]: open }))}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? <IconChevronDown size={15} className="chev" /> : <IconChevronRight size={15} className="chev" />}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{g.key}</span>
|
||||
<span className="tree-count">{g.items.length}</span>
|
||||
</button>
|
||||
{open && g.items.map((a) => (
|
||||
<Link key={a.id} href={"/editor/" + a.id} className={`tree-leaf${activeId === a.id ? " active" : ""}`} title={a.title}>
|
||||
<IconFileText size={15} style={{ flex: "none" }} />
|
||||
<span>{a.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? <IconSun size={18} /> : <IconMoon size={18} />}
|
||||
</button>
|
||||
<Link href="/" className="btn btn-primary btn-block">
|
||||
<IconPencilPlus size={17} /> <span className="navrow-label">New assignment</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user