Test and deploy / test-and-deploy (push) Successful in 53s
Academy (/academy, 'Learn' nav group): - 10 lessons / 45 slides across two tracks. Roasting: seed anatomy and composition, drying & the turning point, Maillard, first crack & development (DTR), curve/RoR reading (crash, flick, stall), defects → one-change discipline. Brewing: extraction physics (dissolving order, yield, grind, temp/time/ratio), then technique per brewer family — immersion (French press, AeroPress, Clever/Hario Switch, cold brew, cupping), percolation (bloom, V60, Chemex/Kalita, batch, percolator), espresso & pressure (puck prep, shot reading, dialing in, moka) - One cohesive scene system: 25 parameterized animated SVG scenes drawn from the app's palette (beans, curves, phase bars, brewers reusing the silhouette library), CSS keyframe animations, reduced-motion support - Narration: Azure TTS (en-US-Andrew HD, eastus) pre-generated to 45 committed MP3s by scripts/generate-academy-audio.mjs; slides speak phonetic respellings (my-YARD, KEM-ex, MOH-kah…) while captions show normal spelling; hands-free autoplay advances after each clip - Player: slide dots, keyboard arrows/space, per-slide captions Roast detail: 'Open cupping session' button next to cup notes (opens or creates the linked plan's session) — its score and flavors flow into the updated .alog download. Co-Authored-By: Claude Fable 5 <[email protected]>
149 lines
5.0 KiB
JavaScript
149 lines
5.0 KiB
JavaScript
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
|
|
// (planner, account, admin) so each page's own script doesn't reimplement it.
|
|
|
|
// Single source of truth for the page links, grouped by workflow — every page renders this
|
|
// into its <div id="nav-links"> instead of hand-maintaining a diverging copy of the nav.
|
|
// Page-specific tool buttons (the planner's drawers) stay in that page's own markup below it.
|
|
const NAV_GROUPS = [
|
|
{
|
|
title: "Roasting",
|
|
items: [
|
|
{ href: "/app", icon: "◐", label: "Planner" },
|
|
{ href: "/roasts", icon: "∿", label: "Roasts" },
|
|
{ href: "/roasters", icon: "♨", label: "Machines" },
|
|
{ href: "/inventory", icon: "▥", label: "Green lots" },
|
|
{ href: "/cupping", icon: "◒", label: "Cupping" },
|
|
],
|
|
},
|
|
{
|
|
title: "Brewing",
|
|
items: [
|
|
{ href: "/brews", icon: "◉", label: "Brews" },
|
|
{ href: "/beans", icon: "◍", label: "Beans" },
|
|
{ href: "/gear", icon: "⚒", label: "Equipment" },
|
|
],
|
|
},
|
|
{
|
|
title: "Learn",
|
|
items: [{ href: "/academy", icon: "✦", label: "Academy" }],
|
|
},
|
|
{
|
|
title: null,
|
|
items: [
|
|
{ href: "/account", icon: "◔", label: "Account" },
|
|
{ href: "/api-docs", icon: "⌗", label: "API docs" },
|
|
{ href: "/admin", icon: "⚙", label: "Admin", id: "nav-admin", hidden: true },
|
|
],
|
|
},
|
|
];
|
|
|
|
export function renderNavLinks() {
|
|
const mount = document.getElementById("nav-links");
|
|
if (!mount) return;
|
|
mount.replaceChildren(
|
|
...NAV_GROUPS.map((group) => {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "nav-group";
|
|
if (group.title) {
|
|
const title = document.createElement("div");
|
|
title.className = "nav-group-title";
|
|
title.textContent = group.title;
|
|
wrap.append(title);
|
|
}
|
|
for (const item of group.items) {
|
|
const a = document.createElement("a");
|
|
a.className = "nav-item";
|
|
if (item.hidden) a.classList.add("hidden");
|
|
if (item.id) a.id = item.id;
|
|
a.href = item.href;
|
|
if (item.href === location.pathname) a.setAttribute("aria-current", "page");
|
|
const icon = document.createElement("span");
|
|
icon.className = "nav-icon";
|
|
icon.setAttribute("aria-hidden", "true");
|
|
icon.textContent = item.icon;
|
|
const label = document.createElement("span");
|
|
label.className = "nav-label";
|
|
label.textContent = item.label;
|
|
a.append(icon, label);
|
|
wrap.append(a);
|
|
}
|
|
return wrap;
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function initSideNav() {
|
|
renderNavLinks();
|
|
const nav = document.getElementById("side-nav");
|
|
const hamburger = document.getElementById("nav-hamburger");
|
|
const collapseBtn = document.getElementById("nav-collapse");
|
|
const workspace = document.getElementById("app-workspace");
|
|
const overlay = document.getElementById("drawer-overlay");
|
|
const COLLAPSE_KEY = "roastPlannerNavCollapsed";
|
|
|
|
function closeMobileNav() {
|
|
nav.classList.remove("mobile-open");
|
|
hamburger?.setAttribute("aria-expanded", "false");
|
|
if (!document.querySelector(".drawer:not(.hidden)"))
|
|
overlay?.classList.add("hidden");
|
|
}
|
|
hamburger?.addEventListener("click", () => {
|
|
const open = nav.classList.toggle("mobile-open");
|
|
hamburger.setAttribute("aria-expanded", String(open));
|
|
overlay?.classList.toggle("hidden", !open);
|
|
});
|
|
overlay?.addEventListener("click", closeMobileNav);
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") closeMobileNav();
|
|
});
|
|
|
|
function applyCollapsed(collapsed) {
|
|
nav.classList.toggle("collapsed", collapsed);
|
|
workspace?.classList.toggle("nav-collapsed", collapsed);
|
|
if (collapseBtn) {
|
|
collapseBtn.textContent = collapsed ? "»" : "«";
|
|
collapseBtn.setAttribute(
|
|
"aria-label",
|
|
collapsed ? "Expand navigation" : "Collapse navigation",
|
|
);
|
|
}
|
|
}
|
|
applyCollapsed(localStorage.getItem(COLLAPSE_KEY) === "true");
|
|
collapseBtn?.addEventListener("click", () => {
|
|
const next = !nav.classList.contains("collapsed");
|
|
applyCollapsed(next);
|
|
localStorage.setItem(COLLAPSE_KEY, String(next));
|
|
});
|
|
return { closeMobileNav };
|
|
}
|
|
|
|
/** Renders a user's avatar into an element: their profile picture when set, else initial. */
|
|
export function applyAvatar(el, user, cacheBust = false) {
|
|
if (user.hasAvatar) {
|
|
el.textContent = "";
|
|
el.style.backgroundImage = `url("/api/account/avatar${cacheBust ? `?t=${Date.now()}` : ""}")`;
|
|
el.style.backgroundSize = "cover";
|
|
el.style.backgroundPosition = "center";
|
|
} else {
|
|
el.style.backgroundImage = "";
|
|
el.textContent = user.email.charAt(0).toUpperCase();
|
|
}
|
|
}
|
|
|
|
/** Populates the nav's user chip and admin link; redirects to /login when signed out. */
|
|
export async function loadNavUser() {
|
|
const response = await fetch("/api/auth/me");
|
|
if (!response.ok) {
|
|
location.replace("/login");
|
|
return null;
|
|
}
|
|
const { user } = await response.json();
|
|
const emailEl = document.getElementById("account-email");
|
|
if (emailEl) emailEl.textContent = user.email;
|
|
const avatarEl = document.getElementById("nav-user-avatar");
|
|
if (avatarEl) applyAvatar(avatarEl, user);
|
|
if (user.role === "admin")
|
|
document.getElementById("nav-admin")?.classList.remove("hidden");
|
|
return user;
|
|
}
|