mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
feat(career): profile passport wall, home career card, shareable PNG card (#955)
* feat(career): profile passport wall, home career card, shareable PNG card Career v3, WS2. The identity artifact leaves the plugin tab: - Profile: #v3-profile-passports-mount (core, one div) filled by career on v3:profile-rendered — per-instrument shelves of earned covers, hours, gig count, open-career link. Absent-not-empty. - Home: the plugin-count stat tile becomes #v3-dash-career-slot with the old stat as fallback content; career replaces it with a trading-card tile (leather + foil shine, badge count, hours, closest-stamp ask) on the existing v3:dashboard-rendered event. - Shareable card: static/js/blob-io.js (downloadBlob lifts the idiom duplicated verbatim in settings-io/diagnostics-export — both refactored; copyImageBlob wraps ClipboardItem, returns false to signal the download fallback). Earned passports get Save/Copy card: a natively-drawn 480×640 canvas (leather, stamp ring, stubs+hours line); copy falls back to download with a notice when the clipboard refuses. - Mount-point convention documented in docs/plugin-v3-ui.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(career): external surfaces stay absent until a passport exists CodeRabbit on #955: a bare commitment produced a zero-passport wall and replaced the dashboard fallback. Docs also now say mounts may hold fallback content and plugins REPLACE, never append. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
831117fb96
commit
6272af8d33
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Career on the Profile and Home pages** — the Profile gains a passport
|
||||||
|
wall (earned-badge covers per instrument, hours, gig count; absent until a
|
||||||
|
passport exists), injected through the same mount-point + rendered-event
|
||||||
|
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
|
||||||
|
The home page's plugin-count stat tile becomes a career trading card
|
||||||
|
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
|
||||||
|
built-in fallback when career has no state. Earned passports gain **Save
|
||||||
|
card / Copy card** — a natively-drawn PNG passport card, downloadable or
|
||||||
|
copied straight to the clipboard for pasting outside the app (shared
|
||||||
|
`blob-io` helpers replace the download idiom previously duplicated in
|
||||||
|
settings-io and diagnostics-export).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||||
|
|||||||
@@ -189,3 +189,23 @@ out of the capability graph.
|
|||||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||||
rail 30, popovers 40).
|
rail 30, popovers 40).
|
||||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||||
|
|
||||||
|
## Injecting into core shells (profile, dashboard)
|
||||||
|
|
||||||
|
Core screens that accept plugin sections render **mount points** — usually
|
||||||
|
empty, sometimes holding core's own **fallback content** (the Dashboard's
|
||||||
|
career slot ships the plugin-count stat) — and announce each (re)build with a
|
||||||
|
DOM event, because their `innerHTML` swap wipes anything previously injected.
|
||||||
|
A plugin listens for the event and **replaces the mount's content** (never
|
||||||
|
append — a fallback may be present) by id — the same seam every time:
|
||||||
|
|
||||||
|
| Shell | Event | Mounts |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
|
||||||
|
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
|
||||||
|
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
|
||||||
|
|
||||||
|
Rules: inject on every event (the mount is fresh), keep the section
|
||||||
|
**absent-not-empty** (no state → leave the mount alone / empty), and guard
|
||||||
|
re-wired listeners with a `dataset` flag when your own refresh path can run
|
||||||
|
against an unwiped mount.
|
||||||
|
|||||||
@@ -99,7 +99,8 @@
|
|||||||
.pp-inst-plus { color: #6b7280; }
|
.pp-inst-plus { color: #6b7280; }
|
||||||
|
|
||||||
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||||
subtle grain gradient (no image assets). */
|
subtle grain gradient (no image assets). Keep the hex pairs in sync with
|
||||||
|
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
|
||||||
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
||||||
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
||||||
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
||||||
@@ -600,3 +601,116 @@
|
|||||||
}
|
}
|
||||||
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||||
.pp-nearest-row em { color: #3f3428; }
|
.pp-nearest-row em { color: #3f3428; }
|
||||||
|
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
|
||||||
|
|
||||||
|
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.pp-wall-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
|
||||||
|
.pp-wall-shelf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
|
||||||
|
}
|
||||||
|
.pp-wall-inst {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6b7280;
|
||||||
|
min-width: 3.6rem;
|
||||||
|
}
|
||||||
|
.pp-wall-cover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.1rem;
|
||||||
|
width: 4.2rem;
|
||||||
|
height: 5.6rem;
|
||||||
|
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||||
|
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
|
||||||
|
0 3px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 0.3rem;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.pp-wall-cover:hover { transform: translateY(-3px); }
|
||||||
|
.pp-wall-cover span {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: rgba(240, 226, 195, 0.9);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pp-wall-cover em {
|
||||||
|
font-size: 0.42rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
font-style: normal;
|
||||||
|
color: #d9a253;
|
||||||
|
}
|
||||||
|
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
|
||||||
|
.pp-wall-link {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #22d3ee;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
.pp-wall-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* The home-page career card — a trading card among stat tiles. */
|
||||||
|
.pp-dash-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
text-align: left;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid rgba(217, 162, 83, 0.35);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
|
||||||
|
linear-gradient(160deg, #2b1414, #17111c);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
|
||||||
|
.pp-dash-shine {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
|
||||||
|
transform: translateX(-130%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
|
||||||
|
.pp-dash-head {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #d9a253;
|
||||||
|
}
|
||||||
|
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
|
||||||
|
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
|
||||||
|
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
|
||||||
|
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
|
||||||
|
.pp-dash-ask em { color: #cbd5e1; }
|
||||||
|
|
||||||
|
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pp-dash-card:hover .pp-dash-shine { animation: none; }
|
||||||
|
.pp-wall-cover, .pp-dash-card { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
+229
-2
@@ -458,6 +458,8 @@
|
|||||||
_pp = view;
|
_pp = view;
|
||||||
detectNewBadges(view);
|
detectNewBadges(view);
|
||||||
renderPassports();
|
renderPassports();
|
||||||
|
renderProfileWall();
|
||||||
|
renderDashCard();
|
||||||
if (!_ppBootstrapped) {
|
if (!_ppBootstrapped) {
|
||||||
_ppBootstrapped = true;
|
_ppBootstrapped = true;
|
||||||
// Sync the local drill snapshot once per session — drill progress
|
// Sync the local drill snapshot once per session — drill progress
|
||||||
@@ -643,7 +645,11 @@
|
|||||||
<span class="pp-stamp-tier">BRONZE</span>
|
<span class="pp-stamp-tier">BRONZE</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>
|
||||||
|
<div class="pp-card-actions">
|
||||||
|
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
||||||
|
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
||||||
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||||
@@ -823,6 +829,217 @@
|
|||||||
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shareable passport card (canvas → PNG, save or clipboard) ─────────
|
||||||
|
// Keep in sync with the .pp-leather-* gradients in assets/career.css —
|
||||||
|
// canvas can't consume a CSS class, so the pairs live twice on purpose.
|
||||||
|
const PP_LEATHER_HEX = {
|
||||||
|
guitar: ['#5c2321', '#401412'],
|
||||||
|
bass: ['#1f3252', '#131f36'],
|
||||||
|
keys: ['#1e4034', '#122a21'],
|
||||||
|
drums: ['#3f3f46', '#26262b'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function drawPassportCard(inst, p) {
|
||||||
|
const W = 480;
|
||||||
|
const H = 640;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar;
|
||||||
|
const bg = ctx.createLinearGradient(0, 0, W, H);
|
||||||
|
bg.addColorStop(0, c1);
|
||||||
|
bg.addColorStop(1, c2);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
// Emboss frame
|
||||||
|
ctx.strokeStyle = 'rgba(240,226,195,0.35)';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeRect(18, 18, W - 36, H - 36);
|
||||||
|
// Genre title
|
||||||
|
ctx.fillStyle = 'rgba(240,226,195,0.95)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = '700 34px Georgia, serif';
|
||||||
|
ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80);
|
||||||
|
ctx.font = '400 15px Georgia, serif';
|
||||||
|
ctx.fillStyle = 'rgba(240,226,195,0.55)';
|
||||||
|
ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122);
|
||||||
|
// Stamp ring
|
||||||
|
const gold = p.badge === 'gold';
|
||||||
|
const ink = gold ? '#d9a253' : '#b06a2a';
|
||||||
|
const cy = 330;
|
||||||
|
ctx.strokeStyle = ink;
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(W / 2, cy, 118, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(W / 2, cy, 106, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = ink;
|
||||||
|
ctx.font = '800 26px Georgia, serif';
|
||||||
|
ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190);
|
||||||
|
ctx.font = '600 16px Georgia, serif';
|
||||||
|
ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28);
|
||||||
|
// Facts
|
||||||
|
const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length;
|
||||||
|
const hours = fmtHours(p.seconds_total);
|
||||||
|
ctx.fillStyle = 'rgba(240,226,195,0.75)';
|
||||||
|
ctx.font = '400 17px Georgia, serif';
|
||||||
|
ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512);
|
||||||
|
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||||
|
ctx.font = '400 13px Georgia, serif';
|
||||||
|
ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPassportCard(mode) {
|
||||||
|
if (!_ppBook || !_pp) return;
|
||||||
|
const { inst, gkey } = _ppBook;
|
||||||
|
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||||
|
.find((x) => x.genre_key === gkey);
|
||||||
|
if (!p) return;
|
||||||
|
const canvas = drawPassportCard(inst, p);
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
const fail = (why) => {
|
||||||
|
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: 'Card export failed', message: why });
|
||||||
|
};
|
||||||
|
if (!blob) { fail('The canvas produced no image.'); return; }
|
||||||
|
const filename = `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`;
|
||||||
|
try {
|
||||||
|
const io = await import('/static/js/blob-io.js');
|
||||||
|
if (mode === 'copy') {
|
||||||
|
const ok = await io.copyImageBlob(blob);
|
||||||
|
if (ok) {
|
||||||
|
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: 'Card copied', message: 'Paste it anywhere.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: 'Saved the card instead.' });
|
||||||
|
}
|
||||||
|
io.downloadBlob(blob, filename);
|
||||||
|
} catch (e) { fail('Export helper unavailable.'); }
|
||||||
|
}, 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Career surfaces outside the plugin screen ─────────────────────────
|
||||||
|
// Profile passport wall + the home-page career card. Both inject into
|
||||||
|
// core-owned mounts announced by v3:profile-rendered /
|
||||||
|
// v3:dashboard-rendered (the achievements seam). Absent-not-empty: with
|
||||||
|
// no committed instrument they render nothing and the dashboard keeps
|
||||||
|
// its built-in fallback stat.
|
||||||
|
|
||||||
|
function careerTotals() {
|
||||||
|
if (!_pp) return null;
|
||||||
|
let badges = 0;
|
||||||
|
let seconds = 0;
|
||||||
|
let gigs = 0;
|
||||||
|
const walls = [];
|
||||||
|
for (const inst of (_pp.config || {}).instruments || []) {
|
||||||
|
const d = (_pp.instruments || {})[inst];
|
||||||
|
// A commitment with no opened passport isn't a wall yet — the
|
||||||
|
// external surfaces (profile, home card) stay ABSENT until a
|
||||||
|
// passport exists (absent-not-empty).
|
||||||
|
if (!d || !d.committed_at || !(d.passports || []).length) continue;
|
||||||
|
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold');
|
||||||
|
badges += earned.length;
|
||||||
|
seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0);
|
||||||
|
gigs += d.gig_count || 0;
|
||||||
|
walls.push({ inst, earned, opened: d.passports.length });
|
||||||
|
}
|
||||||
|
if (!walls.length) return null;
|
||||||
|
return { badges, seconds, gigs, walls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestAskHTML() {
|
||||||
|
if (!_pp) return '';
|
||||||
|
let best = null;
|
||||||
|
for (const inst of (_pp.config || {}).instruments || []) {
|
||||||
|
for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) {
|
||||||
|
if (p.badge !== 'in_progress') continue;
|
||||||
|
const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count);
|
||||||
|
if (!best || need < best.need) best = { p, need };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return '';
|
||||||
|
const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0);
|
||||||
|
if (best.need > 0) {
|
||||||
|
return `${esc(best.p.genre)} — ${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`;
|
||||||
|
}
|
||||||
|
return `${esc(best.p.genre)} — one drill away`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfileWall() {
|
||||||
|
const mount = document.getElementById('v3-profile-passports-mount');
|
||||||
|
if (!mount) return;
|
||||||
|
const totals = careerTotals();
|
||||||
|
if (!totals) { mount.innerHTML = ''; return; }
|
||||||
|
const shelves = totals.walls.map(({ inst, earned, opened }) => {
|
||||||
|
const covers = earned.map((p) =>
|
||||||
|
`<button class="pp-wall-cover pp-leather-${esc(inst)}" data-pp-wall-inst="${esc(inst)}" data-pp-wall-gkey="${esc(p.genre_key)}" title="${esc(p.genre)}">
|
||||||
|
<span>${esc(p.genre.toUpperCase())}</span>
|
||||||
|
<em>${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</em>
|
||||||
|
</button>`).join('');
|
||||||
|
const line = earned.length
|
||||||
|
? covers
|
||||||
|
: `<span class="pp-wall-none">${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending</span>`;
|
||||||
|
return `<div class="pp-wall-shelf"><span class="pp-wall-inst">${esc(ppLabel(inst))}</span>${line}</div>`;
|
||||||
|
}).join('');
|
||||||
|
const hours = fmtHours(totals.seconds);
|
||||||
|
mount.innerHTML = `<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 pp-wall">
|
||||||
|
<div class="pp-wall-head">
|
||||||
|
<span>Passport wall</span>
|
||||||
|
<span class="pp-wall-meta">${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
${shelves}
|
||||||
|
<button class="pp-wall-link" data-pp-wall-career="1">Open career →</button>
|
||||||
|
</div>`;
|
||||||
|
if (!mount.dataset.ppWired) {
|
||||||
|
mount.dataset.ppWired = '1';
|
||||||
|
mount.addEventListener('click', onWallClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWallClick(e) {
|
||||||
|
const open = e.target.closest('[data-pp-wall-inst]');
|
||||||
|
if (open) {
|
||||||
|
// Two attributes, not a '/'-joined pair: a genre key may itself
|
||||||
|
// contain '/' ("drum/bass") and must round-trip intact.
|
||||||
|
const inst = open.dataset.ppWallInst;
|
||||||
|
const gkey = open.dataset.ppWallGkey;
|
||||||
|
lsSet(PP_INST_KEY, inst);
|
||||||
|
if (window.showScreen) window.showScreen('plugin-career');
|
||||||
|
showCareerTab('passports');
|
||||||
|
renderPassports();
|
||||||
|
openBook(inst, gkey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('[data-pp-wall-career]')) {
|
||||||
|
if (window.showScreen) window.showScreen('plugin-career');
|
||||||
|
showCareerTab('passports');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashCard() {
|
||||||
|
const slot = document.getElementById('v3-dash-career-slot');
|
||||||
|
if (!slot) return;
|
||||||
|
const totals = careerTotals();
|
||||||
|
if (!totals) return; // keep core's fallback stat card
|
||||||
|
const hours = fmtHours(totals.seconds);
|
||||||
|
const ask = closestAskHTML();
|
||||||
|
slot.innerHTML = `<button class="pp-dash-card" data-pp-wall-career="1">
|
||||||
|
<span class="pp-dash-shine" aria-hidden="true"></span>
|
||||||
|
<span class="pp-dash-head">Career</span>
|
||||||
|
<span class="pp-dash-badges">${'⚡'.repeat(Math.min(totals.badges, 5))}<b>${totals.badges}</b> badge${totals.badges === 1 ? '' : 's'}</span>
|
||||||
|
<span class="pp-dash-meta">${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||||
|
${ask ? `<span class="pp-dash-ask">closest: ${ask}</span>` : ''}
|
||||||
|
</button>`;
|
||||||
|
if (!slot.dataset.ppWired) {
|
||||||
|
slot.dataset.ppWired = '1';
|
||||||
|
slot.addEventListener('click', onWallClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openGenre(inst, genre) {
|
function openGenre(inst, genre) {
|
||||||
fetch(`${API}/passports/open`, {
|
fetch(`${API}/passports/open`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -872,6 +1089,11 @@
|
|||||||
closeBook();
|
closeBook();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const cardBtn = e.target.closest('[data-pp-card]');
|
||||||
|
if (cardBtn) {
|
||||||
|
exportPassportCard(cardBtn.dataset.ppCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dlBtn = e.target.closest('[data-career-download]');
|
const dlBtn = e.target.closest('[data-career-download]');
|
||||||
const delBtn = e.target.closest('[data-career-delete]');
|
const delBtn = e.target.closest('[data-career-delete]');
|
||||||
const playBtn = e.target.closest('[data-career-play]');
|
const playBtn = e.target.closest('[data-career-play]');
|
||||||
@@ -931,6 +1153,10 @@
|
|||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && _ppBook) closeBook();
|
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||||
});
|
});
|
||||||
|
// Core re-renders profile/dashboard shells (innerHTML wipe) and
|
||||||
|
// announces the fresh mount points — same seam achievements uses.
|
||||||
|
document.addEventListener('v3:profile-rendered', renderProfileWall);
|
||||||
|
document.addEventListener('v3:dashboard-rendered', renderDashCard);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,7 +1164,8 @@
|
|||||||
// the badge-diff logic; nothing here touches the DOM.
|
// the badge-diff logic; nothing here touches the DOM.
|
||||||
window.__careerPassportTest = {
|
window.__careerPassportTest = {
|
||||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||||
fmtHours, ppFillFraction,
|
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||||
|
setView(v) { _pp = v; },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -150,3 +150,30 @@ test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
|||||||
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
||||||
assert.equal(ppFillFraction(null), 0);
|
assert.equal(ppFillFraction(null), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('careerTotals / wall + dash card stay absent without commitment', () => {
|
||||||
|
const w = load();
|
||||||
|
const t = w.__careerPassportTest;
|
||||||
|
// No _pp at all → null; committed-less view → null (absent-not-empty).
|
||||||
|
assert.equal(t.careerTotals(), null);
|
||||||
|
t.setView({ config: { instruments: ['guitar'] },
|
||||||
|
instruments: { guitar: { committed_at: null, passports: [] } } });
|
||||||
|
assert.equal(t.careerTotals(), null);
|
||||||
|
// Committed but zero passports opened: still absent (no zero-wall).
|
||||||
|
t.setView({ config: { instruments: ['guitar'] },
|
||||||
|
instruments: { guitar: { committed_at: 'x', passports: [] } } });
|
||||||
|
assert.equal(t.careerTotals(), null);
|
||||||
|
// Committed with an earned badge + hours → totals aggregate.
|
||||||
|
t.setView({ config: { instruments: ['guitar', 'bass'] },
|
||||||
|
instruments: {
|
||||||
|
guitar: { committed_at: 'x', passports: [
|
||||||
|
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
|
||||||
|
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
|
||||||
|
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
|
||||||
|
bass: { committed_at: null, passports: [] },
|
||||||
|
} });
|
||||||
|
const totals = t.careerTotals();
|
||||||
|
assert.equal(totals.badges, 1);
|
||||||
|
assert.equal(totals.seconds, 3720);
|
||||||
|
assert.equal(totals.walls.length, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Blob export helpers — the download idiom that used to be duplicated in
|
||||||
|
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
|
||||||
|
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
|
||||||
|
// plugins reach it via dynamic import('/static/js/blob-io.js').
|
||||||
|
|
||||||
|
export function downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy an image blob to the system clipboard. Returns true on success, false
|
||||||
|
// when the Clipboard API is unavailable or refuses (insecure context, no user
|
||||||
|
// gesture, permission denied) — callers fall back to downloadBlob and say so.
|
||||||
|
export async function copyImageBlob(blob) {
|
||||||
|
try {
|
||||||
|
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
// redact toggles.
|
// redact toggles.
|
||||||
// 3. Stream the returned zip to disk.
|
// 3. Stream the returned zip to disk.
|
||||||
|
|
||||||
|
import { downloadBlob } from './blob-io.js';
|
||||||
|
|
||||||
function _diagIncludeFromUI() {
|
function _diagIncludeFromUI() {
|
||||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||||
return {
|
return {
|
||||||
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
downloadBlob(blob, filename);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
status.textContent = `Exported ${filename}`;
|
status.textContent = `Exported ${filename}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = `Export failed during download: ${e.message}`;
|
status.textContent = `Export failed during download: ${e.message}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Settings backup — the export / import bundle.
|
// Settings backup — the export / import bundle.
|
||||||
//
|
//
|
||||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
|
||||||
//
|
//
|
||||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
// phase 2; the localStorage side is best-effort merge after server
|
// phase 2; the localStorage side is best-effort merge after server
|
||||||
// success. Failures are reported, never silenced.
|
// success. Failures are reported, never silenced.
|
||||||
|
|
||||||
|
import { downloadBlob } from './blob-io.js';
|
||||||
|
|
||||||
export async function exportSettings() {
|
export async function exportSettings() {
|
||||||
const status = document.getElementById('backup-status');
|
const status = document.getElementById('backup-status');
|
||||||
status.textContent = 'Exporting...';
|
status.textContent = 'Exporting...';
|
||||||
@@ -66,14 +68,7 @@ export async function exportSettings() {
|
|||||||
if (match) filename = match[1];
|
if (match) filename = match[1];
|
||||||
}
|
}
|
||||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
downloadBlob(blob, filename);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
status.textContent = `Exported ${filename}`;
|
status.textContent = `Exported ${filename}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = `Export failed: ${e.message}`;
|
status.textContent = `Export failed: ${e.message}`;
|
||||||
|
|||||||
@@ -208,12 +208,17 @@
|
|||||||
'</div></div></div>' +
|
'</div></div></div>' +
|
||||||
continueCard +
|
continueCard +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
// Stats row
|
// Stats row. The third slot belongs to the career plugin (it
|
||||||
|
// replaces the slot's content on v3:dashboard-rendered); the
|
||||||
|
// plugin-count stat is the built-in fallback when career is
|
||||||
|
// absent or has no state yet.
|
||||||
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
|
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
|
||||||
audioRoutingCard() +
|
audioRoutingCard() +
|
||||||
statCard(String(songCount), 'songs', 'text-fb-gold') +
|
statCard(String(songCount), 'songs', 'text-fb-gold') +
|
||||||
|
'<div id="v3-dash-career-slot" class="grid">' +
|
||||||
statCard(String(pluginCount), 'active', 'text-fb-good') +
|
statCard(String(pluginCount), 'active', 'text-fb-good') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
recentSection +
|
recentSection +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,10 @@
|
|||||||
'<div class="space-y-6">' +
|
'<div class="space-y-6">' +
|
||||||
headerCard +
|
headerCard +
|
||||||
bestsCard +
|
bestsCard +
|
||||||
|
// Passport wall — rendered by the career plugin on
|
||||||
|
// v3:profile-rendered (absent-not-empty: nothing shows until a
|
||||||
|
// passport exists).
|
||||||
|
'<div id="v3-profile-passports-mount"></div>' +
|
||||||
// Feats of Power trophy shelf — rendered by the achievements plugin
|
// Feats of Power trophy shelf — rendered by the achievements plugin
|
||||||
// (earned Feats only; hidden-until-earned, so empty when none).
|
// (earned Feats only; hidden-until-earned, so empty when none).
|
||||||
'<div id="v3-profile-feats-slot"></div>' +
|
'<div id="v3-profile-feats-slot"></div>' +
|
||||||
|
|||||||
Reference in New Issue
Block a user