Test and deploy / test-and-deploy (push) Successful in 56s
Root cause of the broken diagrams found and fixed: CSS transform animations were overriding the SVG positioning transform on the same element, throwing animated elements (beans, flames) to the canvas origin — the bean/flame helpers now split position (outer group) from animation (inner group). Every scene was then rendered in headless Chromium and screenshot- inspected, with fixes applied and re-verified: drying-scene label collision, RoR ideal-line label overlap, first-crack dot off the curve, composition bar stagger orphaning its labels, moka flame hidden behind the base. New opening slide (user request): 'The whole journey, in one bean' — a large bean on a 14-second loop living the full roast (heat in, drying steam out, green→yellow→brown, pressure swell, crack) synced to a stage timeline with a riding marker; narrated like the rest. Co-Authored-By: Claude Fable 5 <[email protected]>
223 lines
7.0 KiB
JavaScript
223 lines
7.0 KiB
JavaScript
import { protectedFetch } from "./api.js?v=__ASSET_VERSION__";
|
|
import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__";
|
|
import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__";
|
|
import { SCENES, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
|
|
|
|
// ── Progress ───────────────────────────────────────────────────────────────
|
|
|
|
const PROGRESS_KEY = "academyProgress.v1";
|
|
function loadProgress() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) ?? {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
function saveProgress(lessonId, patch) {
|
|
const all = loadProgress();
|
|
all[lessonId] = { ...all[lessonId], ...patch };
|
|
try {
|
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(all));
|
|
} catch {
|
|
/* storage unavailable */
|
|
}
|
|
}
|
|
|
|
// ── Player ─────────────────────────────────────────────────────────────────
|
|
|
|
let current = null; // {track, lesson, index}
|
|
const audio = new Audio();
|
|
let autoplay = true;
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function renderMenu() {
|
|
const progress = loadProgress();
|
|
const mount = $("academy-menu");
|
|
mount.replaceChildren(
|
|
...ACADEMY_TRACKS.map((track) => {
|
|
const wrap = document.createElement("section");
|
|
wrap.className = "panel-card";
|
|
const head = document.createElement("div");
|
|
head.className = "panel-head";
|
|
const h2 = document.createElement("h2");
|
|
h2.textContent = track.name;
|
|
head.append(h2);
|
|
const body = document.createElement("div");
|
|
body.className = "panel-body";
|
|
const blurb = document.createElement("p");
|
|
blurb.className = "muted";
|
|
blurb.style.marginTop = "0";
|
|
blurb.textContent = track.blurb;
|
|
body.append(blurb);
|
|
const list = document.createElement("div");
|
|
list.className = "academy-lessons";
|
|
track.lessons.forEach((lesson, li) => {
|
|
const state = progress[lesson.id] ?? {};
|
|
const resumeAt = state.done ? 0 : (state.slide ?? 0);
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "academy-lesson";
|
|
const num = document.createElement("span");
|
|
num.className = "academy-lesson-num";
|
|
num.textContent = state.done ? "✓" : String(li + 1);
|
|
if (state.done) num.classList.add("done");
|
|
const mid = document.createElement("span");
|
|
mid.className = "academy-lesson-mid";
|
|
const label = document.createElement("span");
|
|
label.className = "academy-lesson-label";
|
|
label.textContent = lesson.title;
|
|
const meta = document.createElement("span");
|
|
meta.className = "academy-lesson-meta";
|
|
meta.textContent = state.done
|
|
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
|
|
: resumeAt > 0
|
|
? `resume at slide ${resumeAt + 1} of ${lesson.slides.length}`
|
|
: `${lesson.slides.length} slides · ~${lesson.minutes} min`;
|
|
const bar = document.createElement("span");
|
|
bar.className = "academy-progress";
|
|
const fill = document.createElement("span");
|
|
fill.className = "academy-progress-fill";
|
|
fill.style.width = state.done
|
|
? "100%"
|
|
: `${Math.round((resumeAt / lesson.slides.length) * 100)}%`;
|
|
bar.append(fill);
|
|
mid.append(label, meta, bar);
|
|
btn.append(num, mid);
|
|
btn.addEventListener("click", () => openLesson(track, lesson, resumeAt));
|
|
list.append(btn);
|
|
});
|
|
body.append(list);
|
|
wrap.append(head, body);
|
|
return wrap;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function openLesson(track, lesson, index) {
|
|
current = { track, lesson, index };
|
|
$("academy-menu").classList.add("hidden");
|
|
$("academy-player").classList.remove("hidden");
|
|
renderSlide();
|
|
}
|
|
|
|
function closeLesson() {
|
|
audio.pause();
|
|
current = null;
|
|
$("academy-player").classList.add("hidden");
|
|
$("academy-menu").classList.remove("hidden");
|
|
renderMenu(); // reflect fresh progress
|
|
}
|
|
|
|
function renderSlide() {
|
|
const { lesson, index, track } = current;
|
|
const slide = lesson.slides[index];
|
|
saveProgress(lesson.id, { slide: index });
|
|
$("player-lesson").textContent = `${track.name} — ${lesson.title}`;
|
|
$("player-slide-title").textContent = slide.title;
|
|
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
|
|
|
|
// Rebuilt per slide so CSS animations restart with it.
|
|
const svg = $("player-scene");
|
|
svg.replaceChildren();
|
|
try {
|
|
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
|
} catch {
|
|
svg.append(sceneFallbackText(slide.title));
|
|
}
|
|
|
|
$("player-caption").textContent = slide.transcript;
|
|
|
|
const dots = $("player-dots");
|
|
dots.replaceChildren(
|
|
...lesson.slides.map((s, i) => {
|
|
const dot = document.createElement("button");
|
|
dot.type = "button";
|
|
dot.className = `academy-dot${i === index ? " active" : ""}`;
|
|
dot.setAttribute("aria-label", `Slide ${i + 1}: ${s.title}`);
|
|
dot.addEventListener("click", () => {
|
|
current.index = i;
|
|
renderSlide();
|
|
});
|
|
return dot;
|
|
}),
|
|
);
|
|
$("player-prev").disabled = index === 0;
|
|
$("player-next").textContent =
|
|
index === lesson.slides.length - 1 ? "Finish lesson" : "Next →";
|
|
|
|
audio.src = `/academy-audio/${slide.id}.mp3?v=__ASSET_VERSION__`;
|
|
if (autoplay) audio.play().catch(() => setPlayState(false));
|
|
setPlayState(autoplay);
|
|
}
|
|
|
|
function setPlayState(playing) {
|
|
$("player-play").textContent = playing ? "❚❚" : "▶";
|
|
$("player-play").setAttribute("aria-label", playing ? "Pause narration" : "Play narration");
|
|
}
|
|
|
|
function next() {
|
|
const { lesson, index } = current;
|
|
if (index >= lesson.slides.length - 1) {
|
|
saveProgress(lesson.id, { done: true, slide: 0 });
|
|
closeLesson();
|
|
return;
|
|
}
|
|
current.index++;
|
|
renderSlide();
|
|
}
|
|
|
|
function wirePlayer() {
|
|
$("player-close").addEventListener("click", closeLesson);
|
|
$("player-prev").addEventListener("click", () => {
|
|
if (current.index > 0) {
|
|
current.index--;
|
|
renderSlide();
|
|
}
|
|
});
|
|
$("player-next").addEventListener("click", next);
|
|
$("player-play").addEventListener("click", () => {
|
|
if (audio.paused) {
|
|
autoplay = true;
|
|
audio.play().catch(() => {});
|
|
setPlayState(true);
|
|
} else {
|
|
audio.pause();
|
|
setPlayState(false);
|
|
}
|
|
});
|
|
audio.addEventListener("ended", () => {
|
|
setPlayState(false);
|
|
// Give the animation a beat, then advance — hands-free lesson flow.
|
|
if (autoplay && current) setTimeout(() => current && next(), 1200);
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (!current) return;
|
|
if (event.key === "ArrowRight") next();
|
|
if (event.key === "ArrowLeft" && current.index > 0) {
|
|
current.index--;
|
|
renderSlide();
|
|
}
|
|
if (event.key === "Escape") closeLesson();
|
|
if (event.key === " " && event.target === document.body) {
|
|
event.preventDefault();
|
|
$("player-play").click();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById("btn-logout").addEventListener("click", async () => {
|
|
await protectedFetch("/api/auth/logout", { method: "POST" });
|
|
location.assign("/");
|
|
});
|
|
|
|
async function init() {
|
|
initSideNav();
|
|
const user = await loadNavUser();
|
|
if (!user) return;
|
|
renderMenu();
|
|
wirePlayer();
|
|
}
|
|
|
|
init();
|