diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:03:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:03:22 +0200 |
| commit | ab44526a291fa673aa2850d105f6412a70a5341f (patch) | |
| tree | 5940f18acfc15fc732eb65d90de346920461b8c8 /packages/meshbay-hub/src/meshbay_hub/static/group-page.js | |
| parent | 85a2ec47b7ad334208a3dbb091fadccc7631785c (diff) | |
| download | meshbay-ab44526a291fa673aa2850d105f6412a70a5341f.tar.gz | |
feat(client): Phase 2 — per-app settings panes, folder tree, multi-directory
Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz,
and one folder picker per app, each with its own draft state and save handler
saying the same thing about a different key. They are one file per app now,
reached through the `apps.js` registry, and the page that renders them names no
application at all: adding one is a registry entry and a settings file.
The line between the two is what makes that true. What every app has — folders
— the page does generically, through one `saveDirectories` bound to the app.
What one app alone has, its pane does itself with the transport it is handed.
An app that only needs directories touches neither `group-settings.js` nor
`group-page.js`, which is `test_app_settings_plugin.py`'s subject.
`settings-ui.js` exists because a pane importing the page that renders it is a
cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at
first render — a component that silently does not appear, the fault already
recorded in CLAUDE.md about hook ordering.
The flat depth-indented `<select>` of every folder in the library becomes a
modal tree. It asks the node for nothing: the tree is derived from paths the
client already holds, so it shows exactly what the group's index contains and
adds no folder-browsing protocol. For Chat's attachment folder — the one
directory that is written to rather than read — read-only roots are greyed
out, so the node's refusal arrives before the operator picks rather than when
somebody sends a file.
Videos and Music take a list of folders. A library on two drives could not be
described before; the only recourse was pointing the app at a parent containing
both, which pulls in everything else under it. The scalar shapes survive on the
wire alone, for a node speaking MNP 1.0, and the client reads them as a
one-element list.
Two things the tests caught that I would not have:
`test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached
through the registry rather than imported by name, they are exactly the files
nothing else would notice changing, and a stale one is served from cache with
no version bump.
And `node --check foo.js` does **not** reliably report a module syntax error:
it accepted `${/* ... */''}` — htm template syntax pasted into a plain object
literal — and reported success. A `.mjs` copy forces the module parser and
reports it. The suite had no syntax check at all, which is how that reached a
file; `test_spa_syntax.py` does it for every module now, and pins that the
loose path is not what it uses.
Suite: 12 failures, all pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 107 |
1 files changed, 74 insertions, 33 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index dfde172..747cb74 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -128,15 +128,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // TMDB on/off + whether a custom token is set, node-wide (not per-group) — // docs/mediacenter.md §5.5. Null until the handshake ack arrives. const [tmdbConfig, setTmdbConfig] = useState(null); - // Which folder is the Videos app's entry point for this group — '' - // (the default) means the whole group index. Set from Files, per-group. - const [videoRoot, setVideoRoot] = useState(''); - // Same shape — the Music app's own entry point. - const [audioRoot, setAudioRoot] = useState(''); - // The Photos app's entry points — a *list*, unlike videoRoot/audioRoot - // above (docs/photos.md §2.1: a photo library is routinely scattered - // across several folders). Empty means nothing configured yet. - const [photoRoots, setPhotoRoots] = useState([]); + // Which folders each app works over. One shape for all of them — a list, + // always, even where an app only wants one (docs/refactor-groups.md §1.6): + // Videos and Music were single values, which meant a library spread over two + // drives could not be described at all. Empty means nothing configured yet, + // which every app reads as "show nothing", never "the whole group index". + const [appDirectories, setAppDirectories] = useState({}); + const appDirs = useCallback( + (key) => appDirectories[key] || [], [appDirectories]); + // Where chat attachments are written — one directory, because Chat has one + // destination rather than a set of folders it reads. + const [chatDirectory, setChatDirectory] = useState(''); + const [chatLinkPreview, setChatLinkPreview] = useState(true); // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); const onPlayQueue = useCallback((tracks, startIndex) => { @@ -226,8 +229,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (indexMsg.roots) setNodeRoots(indexMsg.roots); cacheGroupIndex(groupId, group ? group.name : groupId, group ? group.owner_username : null, fresh, - { videoRoot, audioRoot, photoRoots }); - }, [groupId, group, videoRoot, audioRoot, photoRoots]); + { video: appDirs('video'), music: appDirs('music'), + photo: appDirs('photo') }); + }, [groupId, group, appDirs]); // additions/deletions/updates (daemon.py _broadcast_index_change, once // there is a previous snapshot to diff against) — applied on top of @@ -249,10 +253,11 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const fresh = updated.concat(additions); cacheGroupIndex(groupId, group ? group.name : groupId, group ? group.owner_username : null, fresh, - { videoRoot, audioRoot, photoRoots }); + { video: appDirs('video'), music: appDirs('music'), + photo: appDirs('photo') }); return fresh; }); - }, [groupId, group, videoRoot, audioRoot, photoRoots]); + }, [groupId, group, appDirs]); useEffect(() => { let cancelled = false; @@ -326,9 +331,19 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, tokenCustomized: !!ack.tmdb_token_customized, language: ack.tmdb_language || '', }); - setVideoRoot(ack.video_root || ''); - setAudioRoot(ack.audio_root || ''); - setPhotoRoots(ack.photo_roots || []); + // The plural form when the node speaks it, the old scalars when it + // does not — an MNP 1.0 node sends only the latter, and reading its + // missing `video_directories` as "nothing configured" would empty a + // working Videos tab. + setAppDirectories({ + video: ack.video_directories + || (ack.video_root ? [ack.video_root] : []), + music: ack.music_directories + || (ack.audio_root ? [ack.audio_root] : []), + photo: ack.photo_directories || ack.photo_roots || [], + }); + setChatDirectory(ack.chat_directory || ''); + setChatLinkPreview(ack.chat_link_preview !== false); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, }); @@ -344,9 +359,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // recent value. transport.onTmdbConfig = (cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg })); transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled })); - transport.onVideoRoot = (path) => setVideoRoot(path); - transport.onAudioRoot = (path) => setAudioRoot(path); - transport.onPhotoRoots = (roots) => setPhotoRoots(roots); + // One handler for every app's directories, plus the three older + // per-app messages a node that predates the generic op still sends. + transport.onAppDirectories = (app, dirs) => + setAppDirectories((prev) => ({ ...prev, [app]: dirs })); + transport.onVideoRoot = (path) => setAppDirectories( + (prev) => ({ ...prev, video: path ? [path] : [] })); + transport.onAudioRoot = (path) => setAppDirectories( + (prev) => ({ ...prev, music: path ? [path] : [] })); + transport.onPhotoRoots = (roots) => setAppDirectories( + (prev) => ({ ...prev, photo: roots || [] })); + transport.onChatDirectory = (path) => setChatDirectory(path); + transport.onChatLinkPreview = (on) => setChatLinkPreview(on); transport.onMusicbrainzEnabled = (enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled })); transport.onRootsChanged = (msg) => { @@ -600,15 +624,36 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, return !root || !unavailRoots.has(root); }), [entries, unavailRoots]); + // Everything the operator settings panes read, in one object. Built here + // because this is where the state already lives, and passed through + // `group-settings.js` untouched — that page renders the panes without + // knowing what any of them is for, which is what makes adding an app a + // registry entry rather than an edit to the page. + const appSettings = useMemo(() => ({ + videoDirectories: appDirs('video'), + musicDirectories: appDirs('music'), + photoDirectories: appDirs('photo'), + chatDirectory, + chatLinkPreview, + tmdbEnabled: tmdbConfig ? tmdbConfig.enabled !== false : true, + tmdbLanguage: (tmdbConfig && tmdbConfig.language) || '', + tmdbTokenCustomized: Boolean(tmdbConfig && tmdbConfig.tokenCustomized), + musicbrainzEnabled: musicbrainzConfig + ? musicbrainzConfig.enabled !== false : true, + }), [appDirs, chatDirectory, chatLinkPreview, tmdbConfig, musicbrainzConfig]); + const commonProps = { groupId, transportRef, gekRef, status, username, entries, availableEntries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, attachRoot, userId, setError, onPreview, onRefreshIndex: refreshIndex, onActivity: touchActivity, - videoRoot, onVideoRoot: (path) => setVideoRoot(path), - audioRoot, onAudioRoot: (path) => setAudioRoot(path), - photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots), + // Plural everywhere: Videos and Music read a list now, and Photos always + // did. The scalar `videoRoot`/`audioRoot` shapes survive only on the wire, + // for a node that speaks MNP 1.0 — nothing in the client carries them. + videoDirectories: appDirs('video'), + musicDirectories: appDirs('music'), + photoDirectories: appDirs('photo'), tmdbConfig, musicbrainzConfig, onPlayQueue, }; @@ -739,18 +784,14 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onEnabledApps=${(keys) => setEnabledApps(keys)} scanSettings=${scanSettings} onScanSettings=${(s) => setScanSettings(s)} - tmdbConfig=${tmdbConfig} - onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))} - onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))} - musicbrainzConfig=${musicbrainzConfig} - onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))} entries=${entries} nodeDirs=${nodeDirs} - videoRoot=${videoRoot} - onVideoRoot=${(path) => setVideoRoot(path)} - audioRoot=${audioRoot} - onAudioRoot=${(path) => setAudioRoot(path)} - photoRoots=${photoRoots} - onPhotoRoots=${(roots) => setPhotoRoots(roots)} + appSettings=${appSettings} + ${/* The saving pane already knows what it asked for; this is so + the page's own copy moves at the same time, rather than + waiting for the ack it will not be handed (transport.js + resolves an admin ack against the pending request). */''} + onAppDirectories=${(app, dirs) => + setAppDirectories((prev) => ({ ...prev, [app]: dirs }))} onRefreshIndex=${refreshIndex} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> |