/*
* fee[dB]ack v0.3.0 — Lessons (#v3-lessons).
*
* An fb-styled catalog over the external `tutorials` plugin (P25). It lists
* lesson packs + lessons with progress and the unified level/XP/streak, and
* the Start/Continue action deep-links into the plugin's OWN lesson view
* (`plugin-tutorials`) which keeps the video + playSong + run/XP submission.
* We do NOT fork lesson logic or add a second XP curve — completing a lesson
* in the plugin posts to /api/plugins/minigames/runs → the unified core XP
* store (P15), the same level the profile badge reads.
*
* Capability note (design/05 §0/§5): UI placement + plugin nav/screen are a
* DEFERRED capability domain — consume /api/plugins + the plugin REST + the
* legacy globals (navigate/showScreen), NOT capability dispatch. Every fetch
* degrades gracefully (plugin absent / 404 / empty) and never blocks first
* paint. Vanilla JS, fb-* tokens (constitution P-II).
*/
(function () {
'use strict';
const sm = window.slopsmith;
const PLUGIN_ID = 'tutorials';
const API = '/api/plugins/' + PLUGIN_ID;
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const enc = encodeURIComponent;
const jget = async (u) => { try { const r = await fetch(u); return r.ok ? r.json() : null; } catch (e) { return null; } };
// View state survives re-renders so returning from a plugin lesson lands
// back on the pack the user was viewing.
const state = { view: { kind: 'catalog' }, progress: null };
// ── progress helpers ─────────────────────────────────────────────────---
function lessonState(packId, lessonId) {
const packs = (state.progress && state.progress.packs) || {};
const pack = packs[packId] || {};
const lessons = pack.lessons || {};
return lessons[lessonId] || null;
}
function packPassedCount(pack) {
const lessons = (pack && pack.lessons) || [];
return lessons.reduce((n, l) => {
const st = lessonState(pack.id, l.id);
return n + (st && st.passed ? 1 : 0);
}, 0);
}
// The "next" lesson to Start/Continue: first not-passed, else the first.
function nextLesson(pack) {
const lessons = (pack && pack.lessons) || [];
if (!lessons.length) return null;
return lessons.find((l) => { const st = lessonState(pack.id, l.id); return !(st && st.passed); }) || lessons[0];
}
// ── small view pieces ────────────────────────────────────────────────---
function techChips(techs, max) {
const arr = Array.isArray(techs) ? techs : [];
const shown = arr.slice(0, max || 6);
const extra = arr.length - shown.length;
let html = shown.map((t) =>
'' + esc(t) + '').join('');
if (extra > 0) html += '+' + extra + '';
return '
' + html + '
';
}
function progressBar(passed, total) {
const pct = total > 0 ? Math.round((passed / total) * 100) : 0;
return '' +
'
' +
'
' +
'
' + passed + '/' + total + ' ';
}
// Per-lesson status: mastered ⭐, passed ✓, else best-accuracy ramp / "—".
function lessonStatus(packId, lessonId) {
const st = lessonState(packId, lessonId);
if (st && st.mastered) return '★ Mastered';
if (st && st.passed) return '✓ Passed';
if (st && st.best_accuracy != null && st.best_accuracy > 0) {
const acc = st.best_accuracy;
const pct = Math.round(acc * 100);
const color = acc >= 0.9 ? 'text-fb-good' : (acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low');
return '' + pct + '%';
}
return 'Not started';
}
// Unified rank/dB/streak strip (progression spec 010 — replaces the old
// level/XP meter; lesson completions still earn dB via the unified store).
function headerXp(prog) {
const p = prog || { current_streak: 0, best_streak: 0 };
const progression = (window.v3Progression && window.v3Progression.get()) || null;
const rank = progression ? progression.mastery_rank : 0;
const wallet = (progression && progression.wallet) || { balance: 0 };
return '' +
'
' +
'Rank ' + rank + '' +
'' + Number(wallet.balance || 0).toLocaleString() + ' dB' +
'🔥 ' + (p.current_streak || 0) + '-day streak' +
'Best: ' + (p.best_streak || 0) + '
';
}
function pageWrap(inner) {
return '' + inner + '
';
}
function emptyState(title, body, ctaLabel, ctaScreen) {
return pageWrap(
'Lessons
' +
'' +
'
🎓
' +
'
' + esc(title) + '
' +
'
' + esc(body) + '
' +
(ctaLabel ? '
' : '') +
'
');
}
// ── catalog view ─────────────────────────────────────────────────────---
async function renderCatalog(root, prog) {
// /packs returns { packs: [...] }; tolerate a bare array too.
const res = await jget(API + '/packs');
const packs = res && Array.isArray(res.packs) ? res.packs : (Array.isArray(res) ? res : null);
if (!packs) {
root.innerHTML = emptyState('Lessons aren’t installed yet',
'The Tutorials plugin isn’t active. Enable it from the Plugins page to unlock guided lessons.',
'Open Plugins', 'v3-plugins');
wireGo(root);
return;
}
if (!packs.length) {
root.innerHTML = pageWrap(
'Lessons
' + headerXp(prog) +
'No lesson packs installed yet.
');
return;
}
const cards = packs.map((pk) => {
const total = pk.lesson_count || 0;
// Pack summaries don't carry per-lesson ids, so progress count uses
// the progress store keyed by this pack (passed lessons we know of).
const ps = (state.progress && state.progress.packs && state.progress.packs[pk.id]) || {};
const passed = ps.lessons ? Object.values(ps.lessons).filter((l) => l && l.passed).length : 0;
const cover = pk.cover_url
? '
'
: '🎸
';
const cont = passed > 0 && passed < total;
return '' +
'
' +
'
' +
'
' +
techChips(pk.techniques, 5) +
progressBar(passed, total) +
'
' +
'' +
'' +
'
';
}).join('');
root.innerHTML = pageWrap(
'Lessons
' + headerXp(prog) +
'' + cards + '
');
root.querySelectorAll('[data-pack]').forEach((b) =>
b.addEventListener('click', () => openPack(b.getAttribute('data-pack'))));
root.querySelectorAll('[data-start-pack]').forEach((b) =>
b.addEventListener('click', () => startPackById(b.getAttribute('data-start-pack'))));
}
// ── pack detail view ─────────────────────────────────────────────────---
async function renderPack(root, prog, packId) {
const pack = await jget(API + '/packs/' + enc(packId));
if (!pack || !Array.isArray(pack.lessons)) {
// Pack vanished (uninstalled / bad id) — fall back to the catalog.
state.view = { kind: 'catalog' };
return renderCatalog(root, prog);
}
const lessons = pack.lessons;
const passed = packPassedCount(pack);
const cover = pack.cover_url
? '
'
: '🎸
';
const next = nextLesson(pack);
const contLabel = passed > 0 && passed < lessons.length ? 'Continue' : 'Start';
const rows = lessons.map((l, i) => {
const thumb = l.thumb_url
? '
'
: '' + (i + 1) + '
';
const st = lessonState(packId, l.id);
const label = st && st.passed ? 'Replay' : 'Start';
return '' +
'
' + thumb + '
' +
'
' +
'
' + esc(l.title || ('Lesson ' + (i + 1))) + '
' +
'
' + techChips(l.techniques, 5) + '
' +
'
' + lessonStatus(packId, l.id) + '
' +
'
' +
'
';
}).join('');
root.innerHTML = pageWrap(
'' +
headerXp(prog) +
'' +
'
' + cover +
'
' +
'
' +
'
' + esc(pack.title) + '
' +
'
' + esc(pack.author || '') + '
' +
'
' + techChips(pack.techniques, 12) +
progressBar(passed, lessons.length) +
(next ? '' : '') +
'
' +
'' + rows + '
');
root.querySelector('[data-back]')?.addEventListener('click', () => { state.view = { kind: 'catalog' }; render(); });
root.querySelectorAll('[data-lesson]').forEach((b) =>
b.addEventListener('click', () => launchLesson(packId, b.getAttribute('data-lesson'))));
}
// ── navigation into the plugin's own lesson view ─────────────────────---
function launchLesson(packId, lessonId) {
if (!packId || !lessonId) return;
// navigate() stores nav params AND calls showScreen(); the tutorials
// plugin's init() reads getNavParams() to deep-link to {packId, lessonId}.
if (sm && typeof sm.navigate === 'function') {
sm.navigate('plugin-tutorials', { packId: packId, lessonId: lessonId });
} else if (typeof window.showScreen === 'function') {
// Fallback when navigate() is unavailable: stash the same nav params
// navigate() would set so the tutorials plugin can still deep-link
// instead of landing on the generic browse screen.
if (sm) sm._navParams = { packId: packId, lessonId: lessonId };
window.showScreen('plugin-tutorials');
}
}
async function startPackById(packId) {
// Fetch the manifest so we can resolve the pack's next lesson, then launch.
const pack = await jget(API + '/packs/' + enc(packId));
const next = pack && Array.isArray(pack.lessons) ? nextLesson(pack) : null;
if (next) launchLesson(packId, next.id);
else openPack(packId);
}
function openPack(packId) { state.view = { kind: 'pack', packId: packId }; render(); }
function wireGo(root) {
root.querySelectorAll('[data-go]').forEach((b) =>
b.addEventListener('click', () => window.showScreen && window.showScreen(b.getAttribute('data-go'))));
}
// ── entry ────────────────────────────────────────────────────────────---
async function render() {
const root = document.getElementById('v3-lessons');
if (!root) return;
// Refresh unified progress (level/XP/streak) + per-lesson progress every
// render so XP earned in the plugin shows when the user returns.
const [prog, tut] = await Promise.all([
jget('/api/profile/progress'),
jget(API + '/progress'),
]);
state.progress = tut || { packs: {} };
if (state.view.kind === 'pack') await renderPack(root, prog, state.view.packId);
else await renderCatalog(root, prog);
}
window.v3Lessons = { render: render };
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => { if (e && e.detail && e.detail.id === 'v3-lessons') render(); });
sm.on('v3:profile-updated', () => { if (document.getElementById('v3-lessons')?.classList.contains('active')) render(); });
// Refresh the header rank/dB strip when progression state changes while
// the screen is already visible (e.g. after a minigame run on this screen).
sm.on('progression:updated', () => { if (document.getElementById('v3-lessons')?.classList.contains('active')) render(); });
}
})();