aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/menu.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 12:10:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 12:10:25 +0200
commit9cbff21274604e37c0986d57937deef85819c396 (patch)
treebdfb55978bb105fd78d1d35db33ff8bb35457f67 /packages/meshbay-hub/src/meshbay_hub/static/menu.js
parent2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (diff)
downloadmeshbay-9cbff21274604e37c0986d57937deef85819c396.tar.gz
music: the playlist menus
One button in Music's sticky toolbar — load, create, delete, remove a track, sync now — and "add to playlist" on every cover and row. Both surfaces share one list, read from the manifest, so they open instantly with every node offline and no body is fetched until one is wanted. Submenus expand in place rather than flying out: the account menu's language list already does this, and a flyout has nowhere to go at 400px. The tracklist under "remove a track" loads when it is expanded. A name is typed into a field. Electron has no prompt — it throws. Also splits the two playback failures: a decode failure belongs to that file and keeps the bounded counter, a connection failure belongs to the group and skips all of its queued tracks at once. Six dead tracks are one more than the bound, which is where a playlist would otherwise stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/menu.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/menu.js41
1 files changed, 33 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js
index 4aa08ca..1c6a96a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/menu.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js
@@ -19,9 +19,15 @@ import { Icon } from './icon.js';
* downward needs no flipping, no hover intent, and no separate mobile design.
*
* `items` is a flat list, each entry one of:
- * { label, icon?, onSelect } an action
- * { label, icon?, items, empty? } a submenu, expanded in place
+ * { label, icon?, onSelect } an action
+ * { label, icon?, items, empty? } a submenu, expanded in place
+ * { label, icon?, loadItems, empty? } the same, fetched when expanded
* { divider: true }
+ *
+ * `loadItems` exists for the one submenu whose contents are not already in
+ * hand: a playlist's tracklist, which is read out of IndexedDB. Reading every
+ * playlist's tracks to build a menu nobody may open would mean a ten-thousand
+ * track list read on every click of the button.
*/
// Kept away from the viewport edges; `.ctx-menu` sets the width this assumes.
@@ -54,25 +60,44 @@ function useMenu() {
function MenuItems({ items, depth, onClose }) {
const [expanded, setExpanded] = useState(null);
+ // index -> the items that came back, or 'loading'.
+ const [loaded, setLoaded] = useState({});
+
+ const toggle = useCallback((i, it) => {
+ if (expanded === i) { setExpanded(null); return; }
+ setExpanded(i);
+ if (!it.loadItems || loaded[i] !== undefined) return;
+ setLoaded((prev) => ({ ...prev, [i]: 'loading' }));
+ Promise.resolve(it.loadItems())
+ .then((rows) => setLoaded((prev) => ({ ...prev, [i]: rows || [] })))
+ // A submenu that cannot be filled renders as empty rather than as a
+ // spinner nothing will ever replace.
+ .catch(() => setLoaded((prev) => ({ ...prev, [i]: [] })));
+ }, [expanded, loaded]);
return html`
${items.map((it, i) => {
if (it.divider) return html`<div class="ctx-menu-divider" key=${`d${i}`}></div>`;
- if (it.items) {
+ if (it.items || it.loadItems) {
const open = expanded === i;
+ const rows = it.items || loaded[i];
+ const pending = rows === 'loading' || (it.loadItems && rows === undefined);
return html`
<button class="ctx-menu-item" key=${it.key || it.label}
style=${depth ? `padding-left: ${14 + depth * 14}px` : null}
- onClick=${(e) => { e.stopPropagation(); setExpanded(open ? null : i); }}>
+ onClick=${(e) => { e.stopPropagation(); toggle(i, it); }}>
${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`}
<span class="ctx-menu-label">${it.label}</span>
<${Icon} name="chevron" cls="ctx-menu-caret ${open ? 'flip' : ''}" />
</button>
- ${open && (it.items.length
- ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />`
- : html`<div class="ctx-menu-empty"
- style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`)}
+ ${open && (pending
+ ? html`<div class="ctx-menu-empty"
+ style=${`padding-left: ${28 + depth * 14}px`}><span class="spinner"></span></div>`
+ : (rows && rows.length
+ ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />`
+ : html`<div class="ctx-menu-empty"
+ style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`))}
`;
}