summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-28 16:11:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-28 16:11:46 +0200
commit4ef6ca817e36e207f57dd810727f7225e7d26eff (patch)
tree535de76925d84a03c5f3a5e86ff9e0393c6227f5 /packages
parenteba2e6b14484c124f3c87ff95cd7ee640833e5d3 (diff)
downloadmeshbay-4ef6ca817e36e207f57dd810727f7225e7d26eff.tar.gz
feat(hub): search downloads, photos view, pre-connect optimization
Downloads from search: FilesPanel accepts a getTransport callback for cross-group file downloads. Select/Download are available in search; delete and zip are hidden (readOnly). Each download connects to the entry's group via the pool on demand. Photos view: PhotosApp reused in search with the same pattern as Videos/Music — synthetic root, per-entry transport refs, group badges on album cards. Lightbox uses per-entry transport for full-resolution image fetches. Zip download hidden in search context. Pre-connecting: connections to all groups start as soon as indexing finishes, regardless of the active view. Thumbnails cache across pool evictions, so they're ready when the user switches views. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/search-page.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css3
14 files changed, 107 insertions, 37 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index daf13af..75005e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -27,7 +27,7 @@ function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
- showGroup, readOnly,
+ showGroup, readOnly, getTransport,
}) {
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState(() => new Set());
@@ -42,13 +42,18 @@ function FilesPanel({
useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]);
const downloadFile = useCallback(async (entry) => {
- const transport = transportRef.current;
+ let transport, gek;
+ if (getTransport) {
+ const conn = await getTransport(entry);
+ transport = conn.transport;
+ gek = conn.gek;
+ } else {
+ transport = transportRef.current;
+ gek = gekRef.current;
+ }
if (!transport || !transport.connected) return;
- // Both of these have to happen inside the click: a browser grants a file
- // picker, and re-grants a folder, only from a user gesture — so this stays
- // a click handler all the way down into downloadEntry's own picker call.
- await downloadEntry(transfers, transport, gekRef.current, entry);
- }, []);
+ await downloadEntry(transfers, transport, gek, entry);
+ }, [getTransport]);
const uploadFile = useCallback((e) => {
const files = [...(e.target.files || [])];
@@ -271,14 +276,14 @@ function FilesPanel({
// first file ever downloaded.
for (const e of selectedFiles) await downloadFile(e);
}), { disabled: selectedFiles.length === 0 })}
- ${action('archive',
+ ${!readOnly && action('archive',
selectedDirs.length
? t('group.download_zip_n', { n: selectedDirs.length })
: t('group.download_zip_n', { n: 0 }),
() => run(async () => {
for (const d of selectedDirs) await downloadDirectory(d);
}), { disabled: selectedDirs.length === 0 })}
- ${mayEverDelete && action('trash',
+ ${mayEverDelete && !readOnly && action('trash',
deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'),
() => {
const names = [...deletableFiles.map(e => e.name),
@@ -337,14 +342,14 @@ function FilesPanel({
<input type="text" placeholder="${t('group.filter')}"
value=${filter} onInput=${e => setFilter(e.target.value)} />
</div>`}
- ${!readOnly && html`<button class="tb-btn ${selecting ? 'active' : ''}"
+ <button class="tb-btn ${selecting ? 'active' : ''}"
onClick=${() => {
setSelecting(v => !v);
setSelected(new Set());
}}>
<${Icon} name=${selecting ? 'check' : 'checkbox'} />
${selecting ? t('group.select_done') : t('group.select')}
- </button>`}
+ </button>
${selecting && html`<div class="tb-actions">${actionItems}</div>`}
</div>
</div>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index e64d910..e3d9ee2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -533,6 +533,7 @@ export default {
'search.view_files': 'Dateien',
'search.view_videos': 'Videos',
'search.view_music': 'Musik',
+ 'search.view_photos': 'Fotos',
'search.connecting': 'Verbindung zu Gruppen wird hergestellt …',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 06d54a7..e8bfca3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -611,6 +611,7 @@ export default {
'search.view_files': 'Files',
'search.view_videos': 'Videos',
'search.view_music': 'Music',
+ 'search.view_photos': 'Photos',
'search.connecting': 'Connecting to groups...',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index af211c6..0847d79 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -529,6 +529,7 @@ export default {
'search.view_files': 'Archivos',
'search.view_videos': 'Vídeos',
'search.view_music': 'Música',
+ 'search.view_photos': 'Fotos',
'search.connecting': 'Conectando a los grupos…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 92ab2b9..bcc82d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -533,6 +533,7 @@ export default {
'search.view_files': 'Fichiers',
'search.view_videos': 'Vidéos',
'search.view_music': 'Musique',
+ 'search.view_photos': 'Photos',
'search.connecting': 'Connexion aux groupes…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 201fdfc..bfdd964 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -531,6 +531,7 @@ export default {
'search.view_files': 'File',
'search.view_videos': 'Video',
'search.view_music': 'Musica',
+ 'search.view_photos': 'Foto',
'search.connecting': 'Connessione ai gruppi…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index a421e25..5d86e32 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -519,6 +519,7 @@ export default {
'search.view_files': 'ファイル',
'search.view_videos': '動画',
'search.view_music': '音楽',
+ 'search.view_photos': '写真',
'search.connecting': 'グループに接続中…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index b2e3b24..77ba822 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -533,6 +533,7 @@ export default {
'search.view_files': 'Bestanden',
'search.view_videos': 'Video\'s',
'search.view_music': 'Muziek',
+ 'search.view_photos': 'Foto\'s',
'search.connecting': 'Verbinding maken met groepen…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 2c2bacd..23fc4d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -552,6 +552,7 @@ export default {
'search.view_files': 'Pliki',
'search.view_videos': 'Filmy',
'search.view_music': 'Muzyka',
+ 'search.view_photos': 'Zdjęcia',
'search.connecting': 'Łączenie z grupami…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 96dbc75..5c217f0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -530,6 +530,7 @@ export default {
'search.view_files': 'Arquivos',
'search.view_videos': 'Vídeos',
'search.view_music': 'Música',
+ 'search.view_photos': 'Fotos',
'search.connecting': 'Conectando aos grupos…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 45d89f7..b00779d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -505,6 +505,7 @@ export default {
'search.view_files': '文件',
'search.view_videos': '视频',
'search.view_music': '音乐',
+ 'search.view_photos': '照片',
'search.connecting': '正在连接群组…',
// Presence, chat paging, leaving a group, the profile page
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
index 26785e7..d63b384 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
@@ -79,22 +79,23 @@ function albumYearLabel(photos) {
// ── landing grid: one card per album (directory containing images) ─────────
function AlbumCard({ album, transportRef, gekRef, onOpen }) {
- // A photo whose own thumbnail is already ready, over blindly photos[0] —
- // that specific file's enrichment may not have finished yet even though
- // a sibling's has (same fallback video-app.js's PosterGrid already uses
- // picking a show's representative episode).
const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0];
+ const tRef = cover._tRef || transportRef;
+ const gRef = cover._gRef || gekRef;
const year = albumYearLabel(album.photos);
return html`
<div class="photo-album-card" onClick=${onOpen}>
<${MediaThumb} thumbHash=${cover.thumb_hash} alt=${albumTitle(album.dir)}
- cls="photo-album-cover" transportRef=${transportRef} gekRef=${gekRef}
+ cls="photo-album-cover" transportRef=${tRef} gekRef=${gRef}
emptyIcon="image" />
<div class="photo-album-info">
<div class="photo-album-title">${albumTitle(album.dir)}</div>
<div class="photo-album-sub">
${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })}
</div>
+ ${cover.groupName && html`
+ <div class="photo-card-group">${cover.groupName}</div>
+ `}
</div>
</div>
`;
@@ -116,10 +117,12 @@ function AlbumLanding({ albums, transportRef, gekRef, onOpen }) {
// ── open album: grid of its own photos ──────────────────────────────────────
function PhotoTile({ entry, transportRef, gekRef, onOpen }) {
+ const tRef = entry._tRef || transportRef;
+ const gRef = entry._gRef || gekRef;
return html`
<div class="photo-tile" onClick=${onOpen}>
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name}
- cls="photo-tile-thumb" transportRef=${transportRef} gekRef=${gekRef}
+ cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef}
emptyIcon="image" />
</div>
`;
@@ -142,6 +145,8 @@ const ZOOM_MAX = 400;
function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
const entry = photos[index];
+ const tRef = entry._tRef || transportRef;
+ const gRef = entry._gRef || gekRef;
const [blobUrl, setBlobUrl] = useState(() => _fullBlobCache.get(entry.id) || null);
const [loading, setLoading] = useState(!_fullBlobCache.has(entry.id));
// null = "fit to window" (the default, object-fit: contain); a number is
@@ -160,12 +165,12 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
setLoading(true);
let cancelled = false;
(async () => {
- const transport = transportRef.current;
+ const transport = tRef.current;
if (!transport || !transport.connected) { setLoading(false); return; }
try {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
const chunks = await pipelinedDownload(
- transport, gekRef.current, entry.id, totalChunks);
+ transport, gRef.current, entry.id, totalChunks);
if (cancelled) return;
const url = URL.createObjectURL(new Blob(chunks));
_fullBlobCache.set(entry.id, url);
@@ -268,7 +273,7 @@ function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) {
`;
}
-function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) {
+function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) {
const [lightboxIndex, setLightboxIndex] = useState(null);
const zip = useCallback(async () => {
@@ -296,8 +301,8 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) {
${year && html`<span class="photo-album-heading-year">${year}</span>`}
</div>
</div>
- <button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}>
- <${Icon} name="archive" cls="photo-icon-btn-icon" /></button>
+ ${!readOnly && html`<button class="photo-icon-btn" onClick=${zip} title=${t('photo.zip_album')}>
+ <${Icon} name="archive" cls="photo-icon-btn-icon" /></button>`}
</div>
<div class="photo-grid">
${album.photos.map((e, i) => html`
@@ -319,6 +324,7 @@ function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) {
function PhotosApp({
groupId, transportRef, gekRef, status, entries, photoRoots, setError,
+ hideFilter, readOnly,
}) {
const [openDir, setOpenDir] = useState(null);
const [filter, setFilter] = useState('');
@@ -346,11 +352,11 @@ function PhotosApp({
`}
${status === 'connected' && photoRoots && photoRoots.length > 0 && !openAlbum && html`
<div class="photo-toolbar">
- <div class="tb-search">
+ ${!hideFilter && html`<div class="tb-search">
<${Icon} name="search" />
<input type="text" placeholder="${t('group.filter')}"
value=${filter} onInput=${(e) => setFilter(e.target.value)} />
- </div>
+ </div>`}
</div>
${filteredAlbums.length === 0 && html`
<p class="page-message">${needle ? t('group.empty_filter') : t('photo.empty')}</p>
@@ -362,7 +368,7 @@ function PhotosApp({
${status === 'connected' && openAlbum && html`
<${AlbumView} album=${openAlbum} entries=${entries}
transportRef=${transportRef} gekRef=${gekRef} setError=${setError}
- onBack=${() => setOpenDir(null)} />
+ onBack=${() => setOpenDir(null)} readOnly=${readOnly} />
`}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
index 032d26a..5ac3ab6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js
@@ -10,6 +10,7 @@ import {
import { FilesPanel, FilePreview } from './files-app.js';
import { VideoApp, bumpMediaMetaGeneration, bumpThumbGeneration } from './video-app.js';
import { MusicApp, bumpMusicMetaGeneration } from './music-app.js';
+import { PhotosApp } from './photos-app.js';
import { VideoPlayer } from './video-player.js';
import { transfers } from './transfers.js';
@@ -18,6 +19,7 @@ const MAX_POOL_SIZE = 3;
const DEBOUNCE_MS = 200;
const SEARCH_VIDEO_ROOT = '__search__';
const SEARCH_AUDIO_ROOT = '__search__';
+const SEARCH_PHOTO_ROOTS = ['__search_photos__'];
// -- Connection pool ----------------------------------------------------------
@@ -235,18 +237,15 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
return entry;
}, [token, username, userId]);
- // Eagerly connect to groups when entering Videos or Music view
+ // Pre-connect to all groups as soon as indexing finishes so thumbnails
+ // start loading before the user switches views. The pool evicts old
+ // connections but _thumbBlobCache keeps fetched thumbnails across evictions.
useEffect(() => {
- if (viewMode !== 'videos' && viewMode !== 'music') return;
+ if (fetching || indexedGroups.size === 0) return;
let cancelled = false;
(async () => {
- const groupIds = [...indexedGroups.keys()].filter((gid) => {
- const data = indexedGroups.get(gid);
- if (viewMode === 'videos') return !!data.roots.videoRoot;
- return !!data.roots.audioRoot;
- });
-
+ const groupIds = [...indexedGroups.keys()];
for (let i = 0; i < groupIds.length; i += BATCH_SIZE) {
if (cancelled) break;
const batch = groupIds.slice(i, i + BATCH_SIZE);
@@ -257,7 +256,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
})();
return () => { cancelled = true; };
- }, [viewMode, indexedGroups, connectGroup]);
+ }, [fetching, indexedGroups, connectGroup]);
// -- Entry preparation --
@@ -364,6 +363,32 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
return result;
}, [indexedGroups, q, matchesQuery, connectionGen]);
+ // Photos view: pre-filtered by photoRoots, path-prefixed
+ const photoEntries = useMemo(() => {
+ const result = [];
+ for (const [groupId, data] of indexedGroups) {
+ const roots = data.roots.photoRoots;
+ if (!roots || !roots.length) continue;
+ const conn = groupConns.current.get(groupId);
+ for (const e of data.entries) {
+ if (e.type !== 'image') continue;
+ const p = e.path || '';
+ if (!roots.some((r) => p === r || p.startsWith(r + '/'))) continue;
+ if (q && !matchesQuery(e)) continue;
+ result.push({
+ ...e,
+ path: '__search_photos__/' + e.path,
+ groupId,
+ groupName: data.groupName,
+ groupOwner: data.groupOwner,
+ _tRef: conn ? conn.tRef : null,
+ _gRef: conn ? conn.gRef : null,
+ });
+ }
+ }
+ return result;
+ }, [indexedGroups, q, matchesQuery, connectionGen]);
+
// -- Callbacks --
const onPreview = useCallback(async (entry) => {
@@ -417,7 +442,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
await downloadEntry(transfers, transport, modalGekRef.current, entry);
}, []);
- // No-op setters for FilesPanel (read-only mode)
+ const getTransport = useCallback(async (entry) => {
+ const conn = await connectGroup(entry.groupId);
+ return { transport: conn.transport, gek: conn.gek };
+ }, [connectGroup]);
+
+ // No-op setters for FilesPanel
const noop = useCallback(() => {}, []);
// -- Render --
@@ -456,6 +486,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
onClick=${() => setViewMode('music')}
title=${t('search.view_music')}>
<${Icon} name="music" /></button>
+ <button class=${viewMode === 'photos' ? 'active' : ''}
+ onClick=${() => setViewMode('photos')}
+ title=${t('search.view_photos')}>
+ <${Icon} name="image" /></button>
</div>
`}
</div>
@@ -497,7 +531,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
setError=${noop}
onPreview=${onPreview}
showGroup=${true}
- readOnly=${true} />
+ readOnly=${true}
+ getTransport=${getTransport} />
`}
${viewMode === 'videos' && hasResults && html`
@@ -527,6 +562,19 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs })
hideFilter=${true} />
`}
+ ${viewMode === 'photos' && hasResults && html`
+ <${PhotosApp}
+ groupId="search"
+ transportRef=${defaultTRef}
+ gekRef=${defaultGRef}
+ status="connected"
+ entries=${photoEntries}
+ photoRoots=${SEARCH_PHOTO_ROOTS}
+ setError=${noop}
+ hideFilter=${true}
+ readOnly=${true} />
+ `}
+
${!hasResults && !fetching && html`
<p class="page-message">${t('search.hint')}</p>
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index fbd1e70..b315512 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2862,7 +2862,8 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
white-space: nowrap;
}
.video-card-group,
-.music-card-group {
+.music-card-group,
+.photo-card-group {
font-size: 0.72em;
color: var(--accent);
margin-top: 2px;