mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
v3 library: multi-chart work grouping, complete (engine + API + card + drawer + toggle/split/filter-law) — P5a–P5e (#702)
* v3 library: multi-chart work grouping engine + work-charts API — P5a/P5b
Charts of the same song (same normalized artist+title) now GROUP under a
computed work_key, with a materialized representative filter so the grid
can collapse them without breaking keyset paging:
- work_key = normalize(artist+title) (diacritics/punct/case folded,
leading "The" folded on artist); resolves the effective artist via the
artist_alias table when present (feature-detected, no hard dep).
- Sparse, never-purged-on-rescan tables: chart_group_pref(work_key,
preferred_filename) + chart_group_split(filename, split_key); purged
only by the explicit per-song delete.
- Materialized work_display(filename, work_key, effective_work_key,
is_group_representative, group_size) read-model: lazy rebuild via a
dirty flag set on put/delete; set_chart_preferred does an incremental
re-flip (no full rebuild). Auto-pick representative = most
arrangements -> most plays -> newest -> filename; a user pref wins and
degrades to auto-pick if its file disappears.
- group=1 on query_page/query_stats = one extra representative
predicate applied identically to page + total + sort_letters, so the
keyset cursor (sort_value, filename) stays a valid total order and
counts works, not charts. Grouped rows carry chart_count + work_key.
- Charts API: GET /api/work/{work_key}/charts (members + which is the
keeper, your pick vs auto), PUT/DELETE .../preferred, and
POST /api/chart/{filename}/split + /unsplit (under /api/chart so the
DELETE /api/song catch-all can't shadow them).
Tests: 15 grouping-engine + 7 charts-API tests, including grouped
keyset pagination (no skip/dupe across pages).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: grouped grid card + persistent "N charts" chip — P5c
Flip the v3 grid to the grouped library (group=1 on /api/library and the
rail's /api/library/stats fetch): one card per song, showing the
representative (preferred/auto-pick) chart. group rides page, total and
sort_letters identically so the A-Z rail's cumulative-seek math and the
virtualized sizer stay consistent, counting works not charts; the keyset
cursor chains with group on every page.
- New groupingActive() helper, default ON per the design; the persisted
per-view toggle (P5e) lands there. Only the local provider implements
group=; smart collections and remote providers ignore it and stay
flat, so it is safe to send unconditionally.
- chartsChipHtml(): a "flag N charts" chip rendered ONLY when
chart_count >= 2 - single-chart cards emit byte-identical markup.
First in the fixed-height chip row + shrink-0 so it never clips and
card height is unchanged.
- Chip click = feature-detected window.__fbOpenChartsDrawer (the Charts
drawer arrives in P5d); until then a no-op. Plain-click / play / the
arrangement chips are untouched and play the representative.
- The library-home repertoire meter's stats fetch deliberately stays
ungrouped: its mastered numerator counts chart filenames, so a works
denominator could exceed 100% - reconciling that is P5e's
mastery-anchor work. The tree view stays flat (query_artists has no
grouping; its opener is wired in P5d).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: Charts drawer + openers — P5d
The single deep-management surface for a work's charts (design UX-2/3):
a body-appended slide-in drawer (filter-drawer idiom) listing every
chart of the work as a radiogroup — the checked row is the keeper the
grid card plays.
- Rows show format / tuning / arrangements / year / your accuracy (or
"not played") plus the pack filename, usually the only human-readable
distinguisher between duplicate charts. Keeper is labeled
"Preferred (auto)" vs "Preferred - your pick".
- Row click (or Enter/Space) = one-tap Set-preferred; "Reset to auto
pick" appears when the keeper is an explicit pick. Writes go through
the work-charts API and the drawer re-renders from the response; the
grid re-fetches in place since the representative may have flipped.
- Per-row Play (plays that exact chart) and Add-to-playlist (the picker
is z-[200], layering over the z-50 drawer).
- a11y: Tab focus-trap, Escape closes, ArrowUp/Down move focus between
rows (focus only - arrow-select would fire a preferred write per
keystroke), focus restored to the opener on close.
- Openers: the "N charts" chip opens the drawer directly; the card's
overflow menu gains "Charts (N)..." and "Play version >" (expands
inline; picking one plays it as a one-off - the keeper/headline does
not move). Tree rows ride the ungrouped artists endpoint, so the menu
resolves their work lazily via the new GET /api/chart/{fn}/work
({work_key, chart_count}) and slots a "Charts (N)..." entry in when
versions exist. A window.__fbOpenChartsDrawer global lets other views
open the drawer. Right-click is deferred: the open native card
context-menu PR should host that entry once both merge.
- tailwind.min.css rebuilt: carries the new utility classes from this
and the previous commit (the grouped-card chip tint was missing).
Split keys contain '#', so clients MUST URL-encode work_key in paths
(the v3 client does; a test documents the round-trip). 4 new endpoint
tests; 26/26 grouping+charts tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* v3 library: group toggle, split UI, the filter law + mastery-anchor rules — P5e
Completes the multi-chart grouping slice (design 7.1):
- Filter law under group=1: work-identity (artist/album/search) and
practice-state (favorites/mastery/tags/difficulty) predicates stay on
the representative row, while CHART-INTRINSIC predicates (format/
arrangements/stems/lyrics/tuning) now match if ANY member of the work
does - a song you own in Drop D is no longer hidden because your
preferred chart is E Standard. Intrinsic clauses moved to an
alias-aware builder and re-applied as a member EXISTS; identical in
query_page and query_stats so counts and the A-Z rail stay in
lockstep. A pure predicate - keyset paging is untouched (tested).
- Display-chart switch: when the representative itself doesn't match,
the row carries a display_chart override (the matching member). The
row stays the representative's - swapping rows wholesale would break
the (sort_value, filename) cursor - and the card renders/plays the
member while the accuracy badge and heart stay anchored on the
preferred chart.
- Mastery sort aggregates MAX across the group ("a song surfaces on any
chart you've touched"); OFFSET-paged, so cursor-safe. The
Recently-Added aggregate is deliberately deferred: mtime IS a keyset
sort, so its aggregate would need materializing into work_display.
- History-sticky auto-pick: most-played -> most-complete -> newest.
A newer/"more complete" import can't silently take the pick from the
chart your reps accrued on, and a one-off try of an alternate can't
out-rank a practiced incumbent; all-unplayed groups still pick by
completeness.
- Persisted "One card per song" toggle in the filter drawer (default
ON; OFF = one card per chart). A view mode: never counted in the
filter badge, never saved into collection rules, local provider only.
- Split escape hatch: "Split out" per drawer row gives a chart its own
card; the split card's overflow menu offers "Rejoin other versions"
(rows and the chart-work lookup now carry is_split).
- Mastery-anchor heads-up: after set-preferred the drawer shows a
one-line ambient note that practice history stays with each chart
(no toast - hearing-safe).
10 new filter-law tests; 38/38 grouping tests green. tailwind.min.css
rebuilt for the new utility classes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* fix(v3): work-grouping — escape Charts-drawer meta (XSS), keep non-Latin titles distinct, guard mid-rebuild reads (PR #702 review)
- XSS: esc() the composed `meta` string in _chartRowHtml (arrangement/tuning
names come from untrusted feedpak metadata) before innerHTML; acc stays HTML.
- Non-Latin titles: _norm_token falls back to raw lowercased whitespace-collapsed
text when the NFKD+strip fold yields "" (CJK/Cyrillic/Greek/Arabic), so distinct
non-Latin titles keep distinct _work_key values instead of collapsing into one
bogus work. Latin names still hit the folded branch — behavior unchanged.
- Mid-rebuild reads: wrap the grouped representative SELECT in query_page and
query_stats under self._lock (nullcontext when ungrouped, so lazy reads stay
lock-free) so a reader can't observe work_display between rebuild_work_display's
DELETE and INSERT/commit. _ensure_work_display stays OUTSIDE the lock — it
self-locks the rebuild and self._lock is non-reentrant — so only the SELECT is
guarded (rebuild fully completes before the guarded SELECT runs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
a47accd894
commit
e9d95ad190
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+365
-15
@@ -57,6 +57,8 @@
|
||||
const state = {
|
||||
provider: 'local', view: 'grid', sort: 'artist', format: '', q: '',
|
||||
artist: '', album: '',
|
||||
grouping: true, // one card per song (multi-chart grouping); persisted
|
||||
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [] },
|
||||
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [],
|
||||
artistCatalog: [], renderedHash: '',
|
||||
@@ -149,6 +151,7 @@
|
||||
if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
|
||||
if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
|
||||
if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder') state.view = saved.view;
|
||||
if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping;
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
||||
@@ -167,6 +170,7 @@
|
||||
const f = state.filters;
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify({
|
||||
sort: state.sort, format: state.format, view: state.view,
|
||||
grouping: state.grouping !== false,
|
||||
filters: {
|
||||
arr_has: [...f.arr_has], arr_lacks: [...f.arr_lacks],
|
||||
stem_has: [...f.stem_has], stem_lacks: [...f.stem_lacks],
|
||||
@@ -228,6 +232,20 @@
|
||||
return !!(tree.querySelector('[data-fn]') || tree.querySelector('details'));
|
||||
}
|
||||
|
||||
// ── Multi-chart grouping (P5c, design §7.1) ─────────────────────────────
|
||||
// The grid queries with group=1 so charts of the same work collapse to ONE
|
||||
// card — the representative (preferred/auto-pick) chart — with rows carrying
|
||||
// chart_count + work_key from the materialized work_display read-model
|
||||
// (P5a/P5b). group must ride BOTH the page fetch and the rail's stats fetch
|
||||
// so page, total and sort_letters all count works identically — a
|
||||
// works-vs-charts mismatch would break the rail's cumulative-seek math and
|
||||
// the sizer geometry. Grouping is default-ON per the design, with a
|
||||
// persisted toggle in the filter drawer (P5e) — OFF falls back to today's
|
||||
// one-card-per-chart. Only the local provider implements group=; smart
|
||||
// collections and remote providers ignore it and stay flat (their rows
|
||||
// then carry no chart_count, so no ⚑ chips render).
|
||||
function groupingActive() { return state.grouping !== false; }
|
||||
|
||||
function queryParams(extra, opts) {
|
||||
const f = state.filters;
|
||||
const skipArtistAlbum = opts && opts.catalog;
|
||||
@@ -660,6 +678,19 @@
|
||||
'<button data-arr="' + esc(a.index != null ? a.index : '') + '" title="Play ' + esc(a.name) + '" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800/60 text-fb-textDim hover:bg-fb-primary hover:text-white transition">' + esc(a.name) + '</button>').join('');
|
||||
}
|
||||
|
||||
// ⚑ multi-chart chip (P5c, design §7.1): the persistent "other versions
|
||||
// exist" cue on a grouped card. Rendered ONLY when the grouped query says
|
||||
// this card stands for 2+ charts of one work (chart_count = work_display
|
||||
// group_size, P5a) — a single-chart card emits nothing, so its markup stays
|
||||
// byte-identical to an ungrouped card. First in the chip row + shrink-0 so
|
||||
// the overflow-hidden row clips arrangement chips before it ever clips the
|
||||
// cue. Clicking it opens the Charts drawer (see wireCards).
|
||||
function chartsChipHtml(song) {
|
||||
const n = song.chart_count;
|
||||
if (!(n >= 2) || !song.work_key) return '';
|
||||
return '<button data-charts="' + esc(song.work_key) + '" title="' + n + ' charts of this song" aria-label="' + n + ' charts of this song" class="shrink-0 text-[10px] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary border border-fb-primary/40 hover:bg-fb-primary hover:text-white transition">⚑ ' + n + ' charts</button>';
|
||||
}
|
||||
|
||||
// ── Tuning-match flags (working-tuning PR 6) ───────────────────────────────
|
||||
// Colour each song's tuning chip by whether your CURRENT working tuning covers
|
||||
// it: green = play it now, amber = needs a retune. Uses the tuner plugin's
|
||||
@@ -698,16 +729,22 @@
|
||||
function songCard(song) {
|
||||
const fav = song.favorite;
|
||||
const key = cardKey(song);
|
||||
// §7.1 display-chart switch (P5e): under a chart-intrinsic filter the
|
||||
// server may attach `display_chart` — the member that MATCHES the
|
||||
// filter when the representative doesn't. The card SHOWS and PLAYS
|
||||
// that chart, while the row identity (sort keys, data-fn, the
|
||||
// accuracy/heart anchor = the preferred chart) stays the rep's.
|
||||
const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||
// In select mode the checkbox occupies top-2 left-2, so shift the
|
||||
// tuning chip right (left-9) to avoid overlapping it.
|
||||
const tuningLabel = (typeof window.displayTuningName === 'function')
|
||||
? window.displayTuningName(song.tuning_name || song.tuning)
|
||||
: (song.tuning_name || '');
|
||||
? window.displayTuningName(shown.tuning_name || shown.tuning)
|
||||
: (shown.tuning_name || '');
|
||||
let tuning = '';
|
||||
if (tuningLabel) {
|
||||
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
|
||||
? (window.parseRawTuningOffsets(song.tuning_offsets)
|
||||
|| window.parseRawTuningOffsets(song.tuning_name || song.tuning))
|
||||
? (window.parseRawTuningOffsets(shown.tuning_offsets)
|
||||
|| window.parseRawTuningOffsets(shown.tuning_name || shown.tuning))
|
||||
: null;
|
||||
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
|
||||
&& typeof window.displayTuningTargets === 'function')
|
||||
@@ -722,7 +759,7 @@
|
||||
// Also flag a bass-only song (every arrangement is a bass part) so coverage
|
||||
// scores its bass tuning against the bass base pitches, not guitar — otherwise
|
||||
// a 4-string bass tuning read as guitar can false-match a guitar player.
|
||||
const chipArrs = song.arrangements || [];
|
||||
const chipArrs = shown.arrangements || [];
|
||||
const chipIsBass = chipArrs.length > 0
|
||||
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''));
|
||||
const matchAttr = (rawOffsets && rawOffsets.length)
|
||||
@@ -740,7 +777,8 @@
|
||||
const checkbox = state.selectMode
|
||||
? '<input type="checkbox" data-select class="absolute top-2 left-2 z-20 w-5 h-5 accent-fb-primary pointer-events-none"' + (state.selected.has(key) ? ' checked' : '') + '>'
|
||||
: '';
|
||||
const arrChips = arrChipsHtml(song);
|
||||
const arrChips = arrChipsHtml(shown);
|
||||
const chartsChip = chartsChipHtml(song);
|
||||
// Plugin-contributed card actions placed 'inline' (in the hover action
|
||||
// row) or 'overlay' (centered over the art). Menu-placed actions live in
|
||||
// the ⋮ menu (openCardMenu); rendering these here means plugins using
|
||||
@@ -765,20 +803,20 @@
|
||||
const selRing = state.selected.has(key) ? ' ring-2 ring-fb-primary' : '';
|
||||
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(song) + personalBadges(song) + overlay +
|
||||
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + overlay +
|
||||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||||
inlineBtns +
|
||||
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||||
'<button data-save title="Save for later" aria-label="Save for later" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm">🔖</button>' +
|
||||
'<button data-menu title="More" aria-label="More actions" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white text-sm leading-none">⋮</button>' +
|
||||
'</div></div>' +
|
||||
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(song.title) + '">' + esc(song.title) + '</div>' +
|
||||
'<div class="mt-1 text-sm text-fb-text truncate" title="' + esc(shown.title) + '">' + esc(shown.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(song.artist) + '</div>' +
|
||||
// Always emit the chip row (even when empty) at a FIXED single-line
|
||||
// height — uniform card height is what makes the windowed grid's
|
||||
// absolute-position math exact (.v3-card-chips in v3.css).
|
||||
'<div class="v3-card-chips flex gap-1 mt-1">' + arrChips + '</div>' +
|
||||
'<div class="v3-card-chips flex gap-1 mt-1">' + chartsChip + arrChips + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -796,8 +834,23 @@
|
||||
const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
|
||||
// Multi-chart entries (P5d): a grouped grid row carries chart_count +
|
||||
// work_key (P5a annotation), so the menu knows inline whether this card
|
||||
// stands for versions. Local library only — the work API is local.
|
||||
const canCharts = state.provider === 'local' && song.work_key && song.chart_count >= 2;
|
||||
// Play follows the DISPLAYED chart (see wireCards) — under an intrinsic
|
||||
// filter that's the matching member, not the representative.
|
||||
const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||
const rows = [
|
||||
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
|
||||
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(playTarget.filename)); } },
|
||||
...(canCharts ? [
|
||||
{ id: '__charts', label: 'Charts (' + song.chart_count + ')…' },
|
||||
{ id: '__playver', label: 'Play version ▸' },
|
||||
] : []),
|
||||
// Undo for the drawer's "Split out" (P5e) — a split chart is its
|
||||
// own singleton card (no ⚑ chip), so this is its only way back.
|
||||
...(state.provider === 'local' && song.is_split
|
||||
? [{ id: '__unsplit', label: 'Rejoin other versions' }] : []),
|
||||
{ id: '__playlist', label: 'Add to playlist' },
|
||||
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
|
||||
];
|
||||
@@ -816,30 +869,308 @@
|
||||
menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = b.getAttribute('data-act');
|
||||
// 'Play version ▸' swaps THIS menu's rows for the work's charts —
|
||||
// it must expand in place, so it's the one entry that doesn't close.
|
||||
if (id === '__playver') { await _expandPlayVersions(menu, song, closeMenu); return; }
|
||||
closeMenu();
|
||||
if (id === '__play') { playCard(song); return; }
|
||||
if (id === '__play') { playCard(playTarget); return; }
|
||||
if (id === '__charts') { openChartsDrawer(song.work_key, song); return; }
|
||||
if (id === '__unsplit') {
|
||||
if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
|
||||
return;
|
||||
}
|
||||
if (id === '__playlist') { await addFilenamesToPlaylist([song.filename]); return; }
|
||||
if (reg) await reg.run(id, song, { source: 'v3-songs' });
|
||||
}));
|
||||
// Tree rows ride the (ungrouped) artists endpoint, so they don't carry
|
||||
// chart_count/work_key — resolve the work lazily and slot a
|
||||
// "Charts (N)…" entry into the still-open menu when versions exist.
|
||||
// Grid rows already carry both (chart_count defined ⇒ no fetch).
|
||||
if (state.provider === 'local' && song.chart_count === undefined && song.filename) {
|
||||
jget('/api/chart/' + enc(song.filename) + '/work').then((w) => {
|
||||
if (!w || !menu.isConnected) return;
|
||||
const addEntry = (label, run) => {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text';
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); closeMenu(); run(); });
|
||||
menu.appendChild(b);
|
||||
};
|
||||
if (w.chart_count >= 2 && w.work_key) {
|
||||
addEntry('Charts (' + w.chart_count + ')…', () => openChartsDrawer(w.work_key, song));
|
||||
}
|
||||
if (w.is_split) {
|
||||
addEntry('Rejoin other versions', async () => {
|
||||
if (await jsend('POST', '/api/chart/' + enc(song.filename) + '/unsplit')) _groupChanged();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
setTimeout(() => document.addEventListener('click', closer), 0);
|
||||
}
|
||||
|
||||
// 'Play version ▸' (P5d): swap the ⋮ menu's rows for the work's charts;
|
||||
// picking one plays that chart directly. A one-off alternate play — the
|
||||
// keeper/headline doesn't move (stats record to the played chart's own
|
||||
// filename), which is the design's "casual try ≠ deliberate adopt".
|
||||
async function _expandPlayVersions(menu, song, closeMenu) {
|
||||
const data = await jget('/api/work/' + enc(song.work_key) + '/charts');
|
||||
if (!data || !Array.isArray(data.charts) || !data.charts.length || !menu.isConnected) return;
|
||||
menu.innerHTML = data.charts.map((c) => {
|
||||
const tl = (typeof window.displayTuningName === 'function')
|
||||
? window.displayTuningName(c.tuning_name || c.tuning) : (c.tuning_name || '');
|
||||
return '<button data-ver="' + esc(c.filename) + '" title="' + esc(c.filename) + '" class="w-full text-left px-3 py-1.5 hover:bg-fb-card/60 text-fb-text">' +
|
||||
(c.is_representative ? '<span class="text-fb-primary">●</span> ' : '') + esc(c.title) +
|
||||
(tl ? '<span class="text-[10px] text-fb-textDim ml-1">' + esc(tl) + '</span>' : '') +
|
||||
'</button>';
|
||||
}).join('');
|
||||
menu.querySelectorAll('[data-ver]').forEach((vb) => vb.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const fn = vb.getAttribute('data-ver');
|
||||
closeMenu();
|
||||
_saveLibraryScrollSnapshot();
|
||||
if (window.playSong) window.playSong(enc(fn));
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Charts drawer (P5d, design §7.1 UX-2/3) ────────────────────────────────
|
||||
// The single deep-management surface for a work's charts. A body-appended
|
||||
// slide-in panel (the filter-drawer idiom; body-appended like the playlist
|
||||
// picker so it opens from any view) listing every chart of the work as a
|
||||
// radiogroup — the checked row is the keeper the grid card plays. Clicking
|
||||
// an unchecked row (or Enter/Space on it) = Set as preferred, one tap;
|
||||
// "Reset to auto pick" appears when the keeper is your explicit pick.
|
||||
// Writes go through the work-charts API and the drawer re-renders from the
|
||||
// response (there's no server-side library event bus — the drawer is its
|
||||
// own refresh); the grid re-fetches because the representative may have
|
||||
// flipped. Global mode only — the slot-scoped (curated-album) mode is P6;
|
||||
// the per-row Split escape hatch is P5e.
|
||||
let _chartsPrevFocus = null; // focus to restore when the drawer closes
|
||||
|
||||
function _chartsDrawerEls() {
|
||||
let ov = document.getElementById('v3-charts-overlay');
|
||||
let dr = document.getElementById('v3-charts-drawer');
|
||||
if (!ov) {
|
||||
ov = document.createElement('div');
|
||||
ov.id = 'v3-charts-overlay';
|
||||
ov.className = 'fixed inset-0 bg-black/50 z-40 hidden';
|
||||
ov.addEventListener('click', closeChartsDrawer);
|
||||
document.body.appendChild(ov);
|
||||
}
|
||||
if (!dr) {
|
||||
dr = document.createElement('aside');
|
||||
dr.id = 'v3-charts-drawer';
|
||||
dr.className = 'fixed top-0 right-0 h-full w-full sm:w-96 bg-fb-sidebar border-l border-fb-border/50 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto v3-scroll';
|
||||
dr.setAttribute('role', 'dialog');
|
||||
dr.setAttribute('aria-modal', 'true');
|
||||
dr.setAttribute('aria-label', 'Charts of this song');
|
||||
// a11y: Escape closes; Tab is trapped inside the open drawer
|
||||
// (aria-modal alone doesn't trap for keyboard users); ArrowUp/Down
|
||||
// move focus between the chart rows (focus only — selection stays
|
||||
// on Enter/Space, since a native-radio "arrow = select" would fire
|
||||
// a preferred write on every keystroke).
|
||||
dr.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeChartsDrawer(); return; }
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const rows = [...dr.querySelectorAll('[role="radio"]')];
|
||||
if (!rows.length) return;
|
||||
const i = rows.indexOf(document.activeElement);
|
||||
if (i === -1) return;
|
||||
e.preventDefault();
|
||||
rows[(i + (e.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length].focus();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
const foci = dr.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
|
||||
if (!foci.length) return;
|
||||
const first = foci[0], last = foci[foci.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
});
|
||||
document.body.appendChild(dr);
|
||||
}
|
||||
return { ov: ov, dr: dr };
|
||||
}
|
||||
|
||||
// One chart row. `checked` = the current keeper (pref or auto-pick). The
|
||||
// filename line is deliberate: duplicate charts usually share title/artist,
|
||||
// so the pack filename is often the only human-readable distinguisher.
|
||||
function _chartRowHtml(c, data) {
|
||||
const checked = c.is_representative;
|
||||
const prefLabel = checked
|
||||
? (data.preferred_source === 'user' ? 'Preferred — your pick' : 'Preferred (auto)')
|
||||
: '';
|
||||
const tuningLabel = (typeof window.displayTuningName === 'function')
|
||||
? window.displayTuningName(c.tuning_name || c.tuning)
|
||||
: (c.tuning_name || '');
|
||||
const meta = [fmtLabel(c), tuningLabel,
|
||||
(c.arrangements || []).map((a) => a.name).join('/'),
|
||||
c.year ? String(c.year) : '']
|
||||
.filter(Boolean).join(' · ');
|
||||
const acc = (typeof c.best_accuracy === 'number')
|
||||
? '<span class="font-bold ' + (c.best_accuracy >= MASTERY_ACCURACY ? 'text-fb-good' : c.best_accuracy >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(c.best_accuracy * 100) + '%</span>'
|
||||
: '<span class="text-fb-textDim/60">not played</span>';
|
||||
return '<div role="radio" aria-checked="' + (checked ? 'true' : 'false') + '" tabindex="0" data-ch="' + esc(c.filename) + '"' +
|
||||
' title="' + (checked ? esc(prefLabel) : 'Make this the preferred chart') + '"' +
|
||||
' class="border rounded-lg p-3 cursor-pointer transition ' + (checked ? 'border-fb-primary/60 bg-fb-primary/5' : 'border-fb-border/40 hover:border-fb-border') + '">' +
|
||||
'<div class="flex items-start justify-between gap-2">' +
|
||||
'<div class="min-w-0">' +
|
||||
'<div class="text-sm text-fb-text truncate" title="' + esc(c.title) + '">' + esc(c.title) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(meta) + ' · ' + acc + '</div>' +
|
||||
'<div class="text-[10px] text-fb-textDim/60 truncate" title="' + esc(c.filename) + '">' + esc(c.filename) + '</div>' +
|
||||
(prefLabel ? '<div class="text-[10px] font-semibold text-fb-primary mt-0.5">' + esc(prefLabel) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<button data-ch-play title="Play this chart" aria-label="Play this chart" class="shrink-0 w-8 h-8 rounded-full bg-fb-primary hover:bg-fb-primaryHi text-white text-sm leading-none">▶</button>' +
|
||||
'</div>' +
|
||||
'<div class="flex gap-2 mt-2">' +
|
||||
'<button data-ch-pl class="text-xs px-2 py-1 rounded border border-fb-border/50 text-fb-textDim hover:text-fb-text">+ Playlist</button>' +
|
||||
// Split escape hatch (P5e, §7.1): "these aren't the same song".
|
||||
// Only offered while the work still has 2+ charts — splitting the
|
||||
// last member is meaningless. Undo lives in the split-out card's
|
||||
// ⋮ menu ("Rejoin other versions").
|
||||
(data.count >= 2
|
||||
? '<button data-ch-split title="These aren\'t the same song — give this chart its own card" class="text-xs px-2 py-1 rounded border border-fb-border/50 text-fb-textDim hover:text-fb-accent hover:border-fb-accent/50">Split out</button>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// A preferred/auto flip can change which chart the grid's card stands for —
|
||||
// re-fetch the grid in place (scroll preserved; renderWindow refills from
|
||||
// the current scrollTop). The tree lists every chart flat, so it's
|
||||
// unaffected; rare curate action, so a full re-fetch is fine.
|
||||
function _groupChanged() {
|
||||
if (state.view === 'grid' && groupingActive()) loadGrid(true);
|
||||
}
|
||||
|
||||
function _renderChartsDrawer(data, opts) {
|
||||
const els = _chartsDrawerEls();
|
||||
const dr = els.dr;
|
||||
const head = data.charts.find((c) => c.is_representative) || data.charts[0];
|
||||
// Mastery-anchor heads-up (§7.1): shown once, ambiently, right after a
|
||||
// switch — the headline may drop because history stays with each chart
|
||||
// (motor mastery is arrangement-specific). Text only, no toast/sound.
|
||||
const switchNote = (opts && opts.switched)
|
||||
? '<div class="text-[11px] text-fb-primary/90 border border-fb-primary/30 rounded-md px-2 py-1.5">Practice history stays with each chart — your new pick starts from its own stats.</div>'
|
||||
: '';
|
||||
dr.innerHTML =
|
||||
'<div class="p-5 space-y-4">' +
|
||||
'<div class="flex items-start justify-between gap-2">' +
|
||||
'<div class="min-w-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text truncate" title="' + esc(head.title) + '">' + esc(head.title) + '</h3>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(head.artist) + ' · ' + data.count + ' chart' + (data.count === 1 ? '' : 's') + '</div>' +
|
||||
'</div>' +
|
||||
'<button data-charts-close aria-label="Close" class="text-fb-textDim hover:text-fb-text text-xl leading-none">✕</button>' +
|
||||
'</div>' +
|
||||
switchNote +
|
||||
'<div role="radiogroup" aria-label="Charts of this song" class="space-y-2">' +
|
||||
data.charts.map((c) => _chartRowHtml(c, data)).join('') +
|
||||
'</div>' +
|
||||
(data.preferred_source === 'user'
|
||||
? '<button data-charts-auto class="w-full text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md py-2">Reset to auto pick</button>'
|
||||
: '<div class="text-[11px] text-fb-textDim">Auto pick sticks with a chart you\'ve practised; otherwise most complete → newest. Tap a chart to pin your keeper.</div>') +
|
||||
'</div>';
|
||||
|
||||
dr.querySelector('[data-charts-close]').addEventListener('click', closeChartsDrawer);
|
||||
dr.querySelector('[data-charts-auto]')?.addEventListener('click', async () => {
|
||||
const fresh = await jsend('DELETE', '/api/work/' + enc(data.work_key) + '/preferred');
|
||||
if (!fresh) return;
|
||||
_renderChartsDrawer(fresh);
|
||||
_groupChanged();
|
||||
dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
|
||||
});
|
||||
dr.querySelectorAll('[data-ch]').forEach((row) => {
|
||||
const fn = row.getAttribute('data-ch');
|
||||
const setPreferred = async () => {
|
||||
if (row.getAttribute('aria-checked') === 'true') return; // already the keeper
|
||||
const fresh = await jsend('PUT', '/api/work/' + enc(data.work_key) + '/preferred', { filename: fn });
|
||||
if (!fresh) return;
|
||||
_renderChartsDrawer(fresh, { switched: true });
|
||||
_groupChanged();
|
||||
dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
|
||||
};
|
||||
row.addEventListener('click', setPreferred);
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPreferred(); }
|
||||
});
|
||||
row.querySelector('[data-ch-play]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeChartsDrawer();
|
||||
_saveLibraryScrollSnapshot();
|
||||
if (window.playSong) window.playSong(enc(fn));
|
||||
});
|
||||
row.querySelector('[data-ch-pl]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
addFilenamesToPlaylist([fn]);
|
||||
});
|
||||
row.querySelector('[data-ch-split]')?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const ok = await jsend('POST', '/api/chart/' + enc(fn) + '/split');
|
||||
if (!ok) return;
|
||||
_groupChanged();
|
||||
// Re-read the ORIGINAL work — the split chart is gone from it
|
||||
// (now its own card in the grid; its ⋮ menu offers the rejoin).
|
||||
const fresh = await jget('/api/work/' + enc(data.work_key) + '/charts');
|
||||
if (fresh && Array.isArray(fresh.charts) && fresh.charts.length) _renderChartsDrawer(fresh);
|
||||
else closeChartsDrawer();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openChartsDrawer(workKey, _song) {
|
||||
if (!workKey) return;
|
||||
const data = await jget('/api/work/' + enc(workKey) + '/charts');
|
||||
if (!data || !Array.isArray(data.charts) || !data.charts.length) return;
|
||||
const els = _chartsDrawerEls();
|
||||
_chartsPrevFocus = document.activeElement;
|
||||
_renderChartsDrawer(data);
|
||||
els.ov.classList.remove('hidden');
|
||||
els.dr.classList.remove('translate-x-full');
|
||||
els.dr.querySelector('[role="radio"][aria-checked="true"]')?.focus();
|
||||
}
|
||||
|
||||
function closeChartsDrawer() {
|
||||
document.getElementById('v3-charts-overlay')?.classList.add('hidden');
|
||||
document.getElementById('v3-charts-drawer')?.classList.add('translate-x-full');
|
||||
if (_chartsPrevFocus && typeof _chartsPrevFocus.focus === 'function' && _chartsPrevFocus.isConnected) {
|
||||
_chartsPrevFocus.focus();
|
||||
}
|
||||
_chartsPrevFocus = null;
|
||||
}
|
||||
|
||||
// Global opener — the ⚑ chip routes through this, and other views/plugins
|
||||
// (P2's details drawer, dashboards) can open the drawer without reaching
|
||||
// into this module.
|
||||
window.__fbOpenChartsDrawer = openChartsDrawer;
|
||||
|
||||
function wireCards(scope) {
|
||||
scope.querySelectorAll('[data-fn]').forEach((el) => {
|
||||
if (el.dataset.wired) return; // don't double-bind on append/auto-fill
|
||||
el.dataset.wired = '1';
|
||||
const fn = el.getAttribute('data-fn');
|
||||
const song = state.songsById[fn] || { filename: fn };
|
||||
// §7.1: when a chart-intrinsic filter attached a display_chart,
|
||||
// the card SHOWS that member — so play actions target it too
|
||||
// (its arrangement indices match the rendered chips). Identity
|
||||
// actions (heart/save/playlist/menu registry) stay on the rep row.
|
||||
const playTarget = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||
el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
|
||||
if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
|
||||
playCard(song); // local → play; unsynced remote → sync then play
|
||||
playCard(playTarget); // local → play; unsynced remote → sync then play
|
||||
}));
|
||||
el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
|
||||
el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = ab.getAttribute('data-arr');
|
||||
playCard(song, idx === '' ? undefined : Number(idx));
|
||||
playCard(playTarget, idx === '' ? undefined : Number(idx));
|
||||
}));
|
||||
// ⚑ charts chip → the Charts drawer (P5d). Never a card play — the
|
||||
// chip row sits outside [data-v3-play]; stopPropagation is
|
||||
// belt-and-braces.
|
||||
el.querySelector('[data-charts]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
|
||||
});
|
||||
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const btn = e.currentTarget;
|
||||
@@ -1234,6 +1565,7 @@
|
||||
const epoch = state.epoch;
|
||||
const prom = (async () => {
|
||||
const extra = { size: PAGE_SIZE };
|
||||
if (groupingActive()) extra.group = 1;
|
||||
const prevCursor = state.keysetOk ? state.pageCursors[p - 1] : null;
|
||||
if (prevCursor) extra.after = prevCursor; else extra.page = p;
|
||||
const data = await jget('/api/library?' + queryParams(extra).toString());
|
||||
@@ -1414,7 +1746,11 @@
|
||||
// Present letters for the active sort+filter (filter-synced; counts
|
||||
// songs). `sort_letters=1` opts into the active-sort breakdown so the
|
||||
// dashboard / v2 tree (which read only `letters`) skip the extra query.
|
||||
const stats = await jget('/api/library/stats?' + queryParams({ sort_letters: 1 }).toString());
|
||||
// group must MATCH the grid's page fetches (see groupingActive) so the
|
||||
// rail's per-letter counts sum to the same works total the sizer uses.
|
||||
const railParams = { sort_letters: 1 };
|
||||
if (groupingActive()) railParams.group = 1;
|
||||
const stats = await jget('/api/library/stats?' + queryParams(railParams).toString());
|
||||
if (_railToken !== myToken || !railVisible()) { // changed mid-fetch
|
||||
if (_railToken === myToken) rail.classList.add('hidden');
|
||||
return;
|
||||
@@ -1708,6 +2044,15 @@
|
||||
}
|
||||
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
|
||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
|
||||
// Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter
|
||||
// (never counted in the badge, never saved into collection rules).
|
||||
// Local provider only: it's the one that implements group=.
|
||||
(state.provider === 'local'
|
||||
? section('Grouping', '<button data-grouping class="px-2 py-1 rounded-md text-xs border ' +
|
||||
(state.grouping !== false ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') +
|
||||
'" title="Collapse charts of the same song to one card (the ⚑ chip lists the versions)">' +
|
||||
(state.grouping !== false ? '✓ ' : '') + 'One card per song</button>')
|
||||
: '') +
|
||||
// Collections always replay against the LOCAL library, so only offer
|
||||
// "save" when browsing local with a non-empty filter set.
|
||||
(state.provider === 'local' && Object.keys(currentFilterRules()).length
|
||||
@@ -1730,6 +2075,11 @@
|
||||
}));
|
||||
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
|
||||
d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); }));
|
||||
d.querySelector('[data-grouping]')?.addEventListener('click', () => {
|
||||
state.grouping = !(state.grouping !== false);
|
||||
renderDrawer();
|
||||
reload(); // re-fetches grid + rail with/without group=1, saves prefs
|
||||
});
|
||||
d.querySelector('[data-drawer-save]')?.addEventListener('click', saveCurrentAsCollection);
|
||||
d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
|
||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||
|
||||
Reference in New Issue
Block a user