aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 19:03:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 19:03:22 +0200
commitab44526a291fa673aa2850d105f6412a70a5341f (patch)
tree5940f18acfc15fc732eb65d90de346920461b8c8 /packages/meshbay-hub/src/meshbay_hub/static/group-page.js
parent85a2ec47b7ad334208a3dbb091fadccc7631785c (diff)
downloadmeshbay-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.js107
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)} />