diff options
Diffstat (limited to 'packages/meshbay-hub/src')
20 files changed, 1179 insertions, 95 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index f6736e2..8aff952 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -34,7 +34,7 @@ _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", # to what the browser must fetch. "icon.js", "file-utils.js", "hub-client.js", "apps.js", "chat-app.js", "files-app.js", "video-player.js", "video-app.js", - "music-app.js", "music-player.js", + "music-app.js", "music-player.js", "photos-app.js", "group-settings.js", "group-page.js") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/apps.js b/packages/meshbay-hub/src/meshbay_hub/static/apps.js index 08dc353..47b5bba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/apps.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/apps.js @@ -2,6 +2,7 @@ import { ChatPanel } from './chat-app.js'; import { FilesPanel } from './files-app.js'; import { VideoApp } from './video-app.js'; import { MusicApp } from './music-app.js'; +import { PhotosApp } from './photos-app.js'; /** * Every group "application", in tab order. @@ -20,6 +21,7 @@ const APPS = [ { key: 'files', icon: 'folder', labelKey: 'group.tab_files', Component: FilesPanel }, { key: 'video', icon: 'video', labelKey: 'group.tab_video', Component: VideoApp }, { key: 'music', icon: 'music', labelKey: 'group.tab_music', Component: MusicApp }, + { key: 'photo', icon: 'image', labelKey: 'group.tab_photos', Component: PhotosApp }, ]; /** The registry filtered to what this group has enabled, in registry order. */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index a005e2e..b44d105 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -1,5 +1,7 @@ import * as downloads from './downloads.js'; import * as platform from './platform.js'; +import { t } from './i18n.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; const FILE_ICONS = { video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', @@ -200,8 +202,97 @@ async function downloadEntry(transfers, transport, gek, entry) { }); } +/** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + * + * Lifted out of files-app.js (docs/photos.md §3) so photos-app.js's own + * "zip this album" button calls the same implementation rather than a + * second one — nothing here is Files-specific once `entries`/`transport`/ + * `gek`/`setError` are passed in, the same shared-context shape every app + * already receives (apps.md §2). + */ +async function downloadDirectory(transfers, transport, gek, entries, dir, { setError }) { + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. + const target = await _openDownloadTarget(suggested, totalBytes, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }, 0); + if (target === false) return; + if (!target && !confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + const zipOpenRef = { url: null }; + + transfers.start({ + kind: 'download', name: (target && target.name) || suggested, + total: totalBytes, transport, + open: target + ? (target.open || null) + : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, + run: async ({ signal, onProgress }) => { + const writable = target ? target.writable : null; + const parts = writable ? null : []; + let written = 0; + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gek, entry.id, totalChunks, + (bytes) => { written += bytes; onProgress(written, totalBytes); }, + // pipelinedDownload writes in order, which the archive needs. + { write: (plaintext) => zip.write(plaintext) }, signal); + await zip.end(); + } + await zip.finish(); + if (writable) await writable.close(); + else { + const blob = new Blob(parts, { type: 'application/zip' }); + _saveBlob(blob, suggested); + zipOpenRef.url = URL.createObjectURL(blob); + } + } catch (err) { + if (writable) await writable.abort().catch(() => {}); + throw err; + } + }, + }); +} + export { FILE_ICONS, formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry, + downloadDirectory, }; 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 c09ef40..16ab9f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -3,11 +3,11 @@ import { } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; -import { ZipStream, entriesUnder } from './zipstream.js'; +import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, - _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, + pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; // ── Files ──────────────────────────────────────────────────────────────────── @@ -91,88 +91,13 @@ function FilesPanel({ } }, [currentPath]); - /** - * Download a directory as a zip, written straight to disk. - * - * An archive of a group directory is routinely tens of gigabytes, so it is - * never held anywhere: each file is fetched chunk by chunk, decrypted, and - * handed to the zip writer, which hands it to the file the browser opened. - * Peak memory is one chunk plus one small record per file. - * - * Without the File System Access API there is nowhere to stream to, and the - * only alternative is to build the whole thing in memory — so that path is - * offered but says what it costs first. - */ + // The implementation lives in file-utils.js (docs/photos.md §3) so + // photos-app.js's own "zip this album" button can call the same code + // rather than a second one. const downloadDirectory = useCallback(async (dir) => { const transport = transportRef.current; - if (!transport || !transport.connected) return; - - const files = entriesUnder(entries, dir); - if (!files.length) { - setError(t('group.zip_empty')); - return; - } - const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); - const suggested = (dir.split('/').pop() || 'files') + '.zip'; - - // totalBytes decides how this is delivered, but it is not the archive's - // size — headers and the central directory come on top — so it is not - // announced as a Content-Length that the download would then miss. - const target = await _openDownloadTarget(suggested, totalBytes, { - types: [{ description: 'ZIP archive', - accept: { 'application/zip': ['.zip'] } }], - }, 0); - if (target === false) return; - if (!target && !confirm(t('group.zip_no_stream', { - size: formatSize(totalBytes), name: suggested, - }))) { - return; - } - const gek = gekRef.current; - const zipOpenRef = { url: null }; - - transfers.start({ - kind: 'download', name: (target && target.name) || suggested, - total: totalBytes, transport, - open: target - ? (target.open || null) - : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { - const writable = target ? target.writable : null; - const parts = writable ? null : []; - let written = 0; - try { - const zip = new ZipStream(async (bytes) => { - if (writable) await writable.write(bytes); - else parts.push(bytes.slice()); - }); - - for (const { entry, name } of files) { - await zip.begin(name, entry.size, - new Date((entry.added_at || 0) * 1000)); - // A zero-byte file has no chunk to ask for; the header and an empty - // descriptor are the whole entry. - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - if (totalChunks > 0) await pipelinedDownload( - transport, gek, entry.id, totalChunks, - (bytes) => { written += bytes; onProgress(written, totalBytes); }, - // pipelinedDownload writes in order, which the archive needs. - { write: (plaintext) => zip.write(plaintext) }, signal); - await zip.end(); - } - await zip.finish(); - if (writable) await writable.close(); - else { - const blob = new Blob(parts, { type: 'application/zip' }); - _saveBlob(blob, suggested); - zipOpenRef.url = URL.createObjectURL(blob); - } - } catch (err) { - if (writable) await writable.abort().catch(() => {}); - throw err; - } - }, - }); + await sharedDownloadDirectory( + transfers, transport, gekRef.current, entries, dir, { setError }); }, [entries]); const deleteDirectory = useCallback(async (dir) => { 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 ab9dcc5..cdbe612 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -89,6 +89,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, 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([]); // MusicBrainz on/off (per-group) + whether a contact string is configured // (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); @@ -241,6 +245,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }); setVideoRoot(ack.video_root || ''); setAudioRoot(ack.audio_root || ''); + setPhotoRoots(ack.photo_roots || []); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, contactConfigured: !!ack.musicbrainz_contact_configured, @@ -259,6 +264,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transport.onTmdbEnabled = (enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled })); transport.onVideoRoot = (path) => setVideoRoot(path); transport.onAudioRoot = (path) => setAudioRoot(path); + transport.onPhotoRoots = (roots) => setPhotoRoots(roots); transport.onMusicbrainzConfig = (cfg) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg })); transport.onMusicbrainzEnabled = (enabled) => @@ -472,6 +478,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onRefreshIndex: refreshIndex, onActivity: touchActivity, videoRoot, onVideoRoot: (path) => setVideoRoot(path), audioRoot, onAudioRoot: (path) => setAudioRoot(path), + photoRoots, onPhotoRoots: (roots) => setPhotoRoots(roots), tmdbConfig, musicbrainzConfig, onPlayQueue, }; @@ -598,6 +605,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onVideoRoot=${(path) => setVideoRoot(path)} audioRoot=${audioRoot} onAudioRoot=${(path) => setAudioRoot(path)} + photoRoots=${photoRoots} + onPhotoRoots=${(roots) => setPhotoRoots(roots)} onRefreshIndex=${refreshIndex} onLeft=${onLeft} onPaired=${() => setOperatorPaired(true)} /> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index a4db4cb..60b0184 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -101,6 +101,79 @@ function RootFolderRow({ `; } +/** + * Which folder(s) are the Photos app's entry points for this group — a + * *set*, unlike RootFolderRow's single value above (docs/photos.md §2.1: a + * photo library is routinely scattered across several folders). An + * add/remove list rather than a `<select>`: pick a folder to add from the + * same `rootFolderOptions` the Videos/Music pickers use, list what is + * already configured with a remove button each, and one Save signs the + * whole resulting set in one op (same shape as the app-enable checkboxes + * below — several changes staged, one signature). + */ +function PhotoRootsRow({ folders, value, busy, msg, onSave }) { + const [draft, setDraft] = useState(value || []); + useEffect(() => { setDraft(value || []); }, [value]); + const [addSelection, setAddSelection] = useState(''); + + const available = folders.filter((p) => !draft.includes(p)); + const addRoot = () => { + if (!addSelection || draft.includes(addSelection)) return; + setDraft((prev) => [...prev, addSelection].sort()); + setAddSelection(''); + }; + const removeRoot = (path) => setDraft((prev) => prev.filter((p) => p !== path)); + + const unchanged = draft.length === (value || []).length + && draft.every((p) => (value || []).includes(p)); + + return html` + <div class="settings-root-row"> + <div class="settings-root-row-title"> + <${Icon} name="image" /> + <h4>${t('settings_node.photo_roots_title')}</h4> + </div> + <p class="settings-hint">${t('settings_node.photo_roots_hint')}</p> + ${draft.length === 0 && html` + <p class="settings-hint">${t('settings_node.photo_roots_none')}</p> + `} + ${draft.length > 0 && html` + <ul class="settings-root-list"> + ${draft.map((p) => html` + <li key=${p} class="settings-root-list-item"> + <span>${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()}</span> + <button class="link-btn" disabled=${busy} onClick=${() => removeRoot(p)} + title=${t('settings_node.photo_roots_remove')}> + <${Icon} name="close" /></button> + </li> + `)} + </ul> + `} + <div class="settings-row"> + <label class="settings-label"> + <select value=${addSelection} disabled=${busy || available.length === 0} + onChange=${(e) => setAddSelection(e.target.value)}> + <option value="">${t('settings_node.photo_roots_add_placeholder')}</option> + ${available.map((p) => html` + <option key=${p} value=${p}> + ${' '.repeat(p.split('/').length - 1)}${p.split('/').pop()} + </option> + `)} + </select> + </label> + <button class="btn btn-small btn-secondary" disabled=${busy || !addSelection} + onClick=${addRoot}>${t('settings_node.photo_roots_add')}</button> + </div> + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy || unchanged} onClick=${() => onSave(draft)}> + ${busy ? t('settings_node.scan_saving') : t('settings_node.photo_roots_save')} + </button> + ${msg && html`<p class=${msg.ok ? 'success-msg' : 'error-msg'} style="margin-top:8px"> + ${msg.text}</p>`} + </div> + `; +} + // ── Members Panel ──────────────────────────────────────────────────────── /** @@ -120,7 +193,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, tmdbConfig, onTmdbConfig, onTmdbEnabled, musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled, entries, nodeDirs, videoRoot, onVideoRoot, - audioRoot, onAudioRoot, onRefreshIndex, + audioRoot, onAudioRoot, + photoRoots, onPhotoRoots, onRefreshIndex, onPaired, onLeft }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); @@ -636,6 +710,36 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onAudioRoot, audioRootDraft, audioRoot]); + // Photos app's own entry points — a set (docs/photos.md §2.1), unlike + // videoRoot/audioRoot above. No "removing a root is destructive" confirm + // dialog: removing one root only drops that root's albums from view, it + // does not replace the whole tab's content the way changing video_root + // does. + const [photoRootsBusy, setPhotoRootsBusy] = useState(false); + const [photoRootsMsg, setPhotoRootsMsg] = useState(null); + + const savePhotoRoots = useCallback(async (nextRoots) => { + const transport = transportRef && transportRef.current; + setPhotoRootsMsg(null); + setPhotoRootsBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setPhotoRoots(nextRoots, signFn); + if (onPhotoRoots) onPhotoRoots(nextRoots); + setPhotoRootsMsg({ text: t('settings_node.scan_saved'), ok: true }); + } catch (err) { + setPhotoRootsMsg({ text: err.message, ok: false }); + } finally { + setPhotoRootsBusy(false); + } + }, [transportRef, onPhotoRoots]); + const [removing, setRemoving] = useState(''); /** @@ -957,7 +1061,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, this group at all (daemon.py's _enrich_new_video_entries). */ isNodeAdmin && connected && ((nodeDetected && nodeRoots.length > 0) - || activeApps.includes('video') || activeApps.includes('music')) && html` + || activeApps.includes('video') || activeApps.includes('music') + || activeApps.includes('photo')) && html` <${CollapsibleSection} titleKey="settings_node.directories_title"> <p class="settings-hint">${t('settings_node.directories_hint')}</p> @@ -977,6 +1082,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, busy=${audioRootBusy} msg=${audioRootMsg} onSave=${saveAudioRoot} noneKey="settings_node.audio_root_none" saveKey="settings_node.audio_root_save" /> `} + ${activeApps.includes('photo') && html` + <${PhotoRootsRow} + folders=${rootFolderOptions} value=${photoRoots} + busy=${photoRootsBusy} msg=${photoRootsMsg} onSave=${savePhotoRoots} /> + `} ${/* Roots management (Electron-only, when node is local) — folded into the same Directories section as the two root pickers above. */ nodeDetected && nodeRoots.length > 0 && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/icon.js b/packages/meshbay-hub/src/meshbay_hub/static/icon.js index 0ecbd70..029c669 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/icon.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/icon.js @@ -82,6 +82,15 @@ const ICON_PATHS = { 'M7 23l-4-4 4-4', 'M21 13v2a4 4 0 0 1-4 4H3'], volume: ['M11 5L6 9H2v6h4l5 4z', 'M15.54 8.46a5 5 0 0 1 0 7.07', 'M19.07 4.93a10 10 0 0 1 0 14.14'], + image: ['M5 3.5h14a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5V5a1.5 1.5 0 0 1 1.5-1.5z', + 'M7 9.5a1.5 1.5 0 1 0 3 0 1.5 1.5 0 0 0-3 0', + 'M20.5 15l-5-5-9.5 9.5'], + 'zoom-in': ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5', + 'M11 8v6', 'M8 11h6'], + 'zoom-out': ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5', + 'M8 11h6'], + frame: ['M4 9V5a1 1 0 0 1 1-1h4', 'M15 4h4a1 1 0 0 1 1 1v4', + 'M20 15v4a1 1 0 0 1-1 1h-4', 'M9 20H5a1 1 0 0 1-1-1v-4'], }; // The M of the wordmark is a picture; the rest is text. Resolved from this 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 83b5c1f..1e367fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -78,6 +78,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Videos', 'group.tab_music': 'Musik', + 'group.tab_photos': 'Fotos', 'group.tab_members': 'Mitglieder', 'group.tab_settings': "Einstellungen", 'members.danger_leave_hint': "Sie verlieren den Zugriff auf die Dateien und den Chat dieser Gruppe.", @@ -205,6 +206,24 @@ export default { 'music.player_queue': 'Aktuelle Wiedergabeliste', 'music.queue_title': 'Wird wiedergegeben', + // Photos + 'photo.empty': 'Keine Fotos gefunden.', + 'photo.no_roots_configured': 'Für diese Gruppe sind noch keine Fotos-Stammordner festgelegt — ein Operator kann welche in den Einstellungen auswählen.', + 'photo.root_album': '(oberste Ebene)', + 'photo.n_photos': { + one: '{n} Foto', + other: '{n} Fotos', + }, + 'photo.back': 'Zurück', + 'photo.prev': 'Vorherige (←)', + 'photo.next': 'Nächste (→)', + 'photo.zip_album': 'Album als ZIP herunterladen', + 'photo.zoom_in': 'Vergrößern', + 'photo.zoom_out': 'Verkleinern', + 'photo.zoom_fit_title': 'An Fenster anpassen', + 'photo.zoom_fit_label': 'Anpassen', + 'photo.zoom_100': 'Originalgröße (100 %)', + // LAN-Cast 'cast.start': 'Auf Gerät übertragen', 'cast.stop': 'Übertragung beenden', @@ -651,8 +670,15 @@ export default { 'settings_node.audio_root_none': '— keiner ausgewählt —', 'settings_node.audio_root_save': 'Speichern', 'settings_node.audio_root_change_confirm': 'Das Ändern des Musik-Stammordners ersetzt, was jedes Mitglied im Musik-Tab sieht. Fortfahren?', + 'settings_node.photo_roots_title': 'Fotos-Stammordner', + 'settings_node.photo_roots_hint': 'Welche Ordner die Fotos-App als Einstiegspunkte für diese Gruppe nutzt — eine Fotobibliothek ist oft auf mehrere Ordner verteilt, daher können mehrere ausgewählt werden. In Fotos wird nichts angezeigt, bis mindestens einer hinzugefügt wurde.', + 'settings_node.photo_roots_none': '— keiner ausgewählt —', + 'settings_node.photo_roots_add_placeholder': 'Ordner hinzufügen…', + 'settings_node.photo_roots_add': 'Hinzufügen', + 'settings_node.photo_roots_remove': 'Entfernen', + 'settings_node.photo_roots_save': 'Speichern', 'settings_node.directories_title': 'Verzeichnisse', - 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos- und Musik-Apps als eigenen Einstiegspunkt nutzen.', + 'settings_node.directories_hint': 'Freigegebene Ordner und welchen davon die Videos-, Musik- und Fotos-Apps als eigene(n) Einstiegspunkt(e) nutzen.', // Create-group wizard 'wizard.title': 'Gruppe erstellen', 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 1101eb2..5aedd7a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -79,6 +79,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Videos', 'group.tab_music': 'Music', + 'group.tab_photos': 'Photos', 'group.tab_members': 'Members', 'group.tab_settings': "Settings", 'members.danger_leave_hint': "You will lose access to this group's files and chat.", @@ -203,6 +204,24 @@ export default { 'music.player_queue': 'Current queue', 'music.queue_title': 'Playing now', + // Photos + 'photo.empty': 'No photos found.', + 'photo.no_roots_configured': 'No Photos root folders are set for this group yet — an operator can choose some in Settings.', + 'photo.root_album': '(top level)', + 'photo.n_photos': { + one: '{n} photo', + other: '{n} photos', + }, + 'photo.back': 'Back', + 'photo.prev': 'Previous (←)', + 'photo.next': 'Next (→)', + 'photo.zip_album': 'Download album as zip', + 'photo.zoom_in': 'Zoom in', + 'photo.zoom_out': 'Zoom out', + 'photo.zoom_fit_title': 'Fit to window', + 'photo.zoom_fit_label': 'Fit', + 'photo.zoom_100': 'Actual size (100%)', + // LAN cast 'cast.start': 'Cast to device', 'cast.stop': 'Stop casting', @@ -476,8 +495,15 @@ export default { 'settings_node.audio_root_none': '— none chosen —', 'settings_node.audio_root_save': 'Save', 'settings_node.audio_root_change_confirm': 'Changing the Music root replaces what every member sees in the Music tab. Continue?', + 'settings_node.photo_roots_title': 'Photos root folders', + 'settings_node.photo_roots_hint': 'Which folder(s) the Photos app treats as entry points for this group — a photo library is often scattered across several folders, so more than one may be chosen. Nothing shows in Photos until at least one is added.', + 'settings_node.photo_roots_none': '— none chosen —', + 'settings_node.photo_roots_add_placeholder': 'Add a folder…', + 'settings_node.photo_roots_add': 'Add', + 'settings_node.photo_roots_remove': 'Remove', + 'settings_node.photo_roots_save': 'Save', 'settings_node.directories_title': 'Directories', - 'settings_node.directories_hint': 'Shared folders, and which of them the Videos and Music apps use as their own entry point.', + 'settings_node.directories_hint': 'Shared folders, and which of them the Videos, Music and Photos apps use as their own entry point(s).', // Members 'members.col_role': 'Role', 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 d9fd76f..46a4638 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -76,6 +76,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Vídeos', 'group.tab_music': 'Música', + 'group.tab_photos': 'Fotos', 'group.tab_members': 'Miembros', 'group.tab_settings': "Ajustes", 'members.danger_leave_hint': "Perderá el acceso a los archivos y al chat de este grupo.", @@ -203,6 +204,24 @@ export default { 'music.player_queue': 'Cola actual', 'music.queue_title': 'Reproduciendo ahora', + // Photos + 'photo.empty': 'No se encontraron fotos.', + 'photo.no_roots_configured': 'Aún no se han definido carpetas raíz de Fotos para este grupo — un operador puede elegir algunas en Configuración.', + 'photo.root_album': '(nivel superior)', + 'photo.n_photos': { + one: '{n} foto', + other: '{n} fotos', + }, + 'photo.back': 'Atrás', + 'photo.prev': 'Anterior (←)', + 'photo.next': 'Siguiente (→)', + 'photo.zip_album': 'Descargar álbum como zip', + 'photo.zoom_in': 'Acercar', + 'photo.zoom_out': 'Alejar', + 'photo.zoom_fit_title': 'Ajustar a la ventana', + 'photo.zoom_fit_label': 'Ajustar', + 'photo.zoom_100': 'Tamaño real (100%)', + // LAN cast 'cast.start': 'Enviar a dispositivo', 'cast.stop': 'Detener envío', @@ -646,8 +665,15 @@ export default { 'settings_node.audio_root_none': '— ninguna elegida —', 'settings_node.audio_root_save': 'Guardar', 'settings_node.audio_root_change_confirm': 'Cambiar la raíz de Música reemplaza lo que ve cada miembro en la pestaña Música. ¿Continuar?', + 'settings_node.photo_roots_title': 'Carpetas raíz de Fotos', + 'settings_node.photo_roots_hint': 'Qué carpeta(s) trata la app Fotos como puntos de entrada para este grupo — una fototeca suele estar repartida en varias carpetas, así que se pueden elegir varias. No se muestra nada en Fotos hasta que se añada al menos una.', + 'settings_node.photo_roots_none': '— ninguna elegida —', + 'settings_node.photo_roots_add_placeholder': 'Añadir una carpeta…', + 'settings_node.photo_roots_add': 'Añadir', + 'settings_node.photo_roots_remove': 'Quitar', + 'settings_node.photo_roots_save': 'Guardar', 'settings_node.directories_title': 'Directorios', - 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos y Música como su propio punto de entrada.', + 'settings_node.directories_hint': 'Carpetas compartidas, y cuál de ellas usan las apps de Vídeos, Música y Fotos como su(s) propio(s) punto(s) de entrada.', // Create group wizard 'wizard.title': 'Crear grupo', 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 7e0b13f..5cd8894 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -77,6 +77,7 @@ export default { 'group.tab_chat': 'Discussion', 'group.tab_video': 'Vidéos', 'group.tab_music': 'Musique', + 'group.tab_photos': 'Photos', 'group.tab_members': 'Membres', 'group.tab_settings': "Paramètres", 'members.danger_leave_hint': "Vous perdrez l’accès aux fichiers et à la discussion de ce groupe.", @@ -204,6 +205,24 @@ export default { 'music.player_queue': 'File en cours', 'music.queue_title': 'En cours de lecture', + // Photos + 'photo.empty': 'Aucune photo trouvée.', + 'photo.no_roots_configured': "Aucun dossier racine des Photos n'est encore défini pour ce groupe — un opérateur peut en choisir dans les Paramètres.", + 'photo.root_album': '(niveau racine)', + 'photo.n_photos': { + one: '{n} photo', + other: '{n} photos', + }, + 'photo.back': 'Retour', + 'photo.prev': 'Précédent (←)', + 'photo.next': 'Suivant (→)', + 'photo.zip_album': "Télécharger l'album en zip", + 'photo.zoom_in': 'Zoomer', + 'photo.zoom_out': 'Dézoomer', + 'photo.zoom_fit_title': 'Ajuster à la fenêtre', + 'photo.zoom_fit_label': 'Ajusté', + 'photo.zoom_100': 'Taille réelle (100 %)', + // LAN cast 'cast.start': 'Diffuser sur un appareil', 'cast.stop': 'Arrêter la diffusion', @@ -662,8 +681,15 @@ export default { 'settings_node.audio_root_none': '— aucun choisi —', 'settings_node.audio_root_save': 'Enregistrer', 'settings_node.audio_root_change_confirm': 'Changer la racine de la Musique remplace ce que chaque membre voit dans l\'onglet Musique. Continuer ?', + 'settings_node.photo_roots_title': 'Dossiers racines des Photos', + 'settings_node.photo_roots_hint': 'Quel(s) dossier(s) l\'application Photos traite comme points d\'entrée pour ce groupe — une photothèque est souvent répartie sur plusieurs dossiers, donc plusieurs peuvent être choisis. Rien ne s\'affiche dans Photos tant qu\'aucun n\'est ajouté.', + 'settings_node.photo_roots_none': '— aucun choisi —', + 'settings_node.photo_roots_add_placeholder': 'Ajouter un dossier…', + 'settings_node.photo_roots_add': 'Ajouter', + 'settings_node.photo_roots_remove': 'Retirer', + 'settings_node.photo_roots_save': 'Enregistrer', 'settings_node.directories_title': 'Répertoires', - 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos et Musique utilisent comme leur propre point d\'entrée.', + 'settings_node.directories_hint': 'Dossiers partagés, et lequel d\'entre eux les applications Vidéos, Musique et Photos utilisent comme leur(s) propre(s) point(s) d\'entrée.', // Create group wizard 'wizard.title': 'Créer un groupe', 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 7d2fada..26e4e8f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -77,6 +77,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': 'Video', 'group.tab_music': 'Musica', + 'group.tab_photos': 'Foto', 'group.tab_members': 'Membri', 'group.tab_settings': "Impostazioni", 'members.danger_leave_hint': "Perderai l’accesso ai file e alla chat di questo gruppo.", @@ -204,6 +205,24 @@ export default { 'music.player_queue': 'Coda attuale', 'music.queue_title': 'In riproduzione', + // Photos + 'photo.empty': 'Nessuna foto trovata.', + 'photo.no_roots_configured': 'Per questo gruppo non sono ancora impostate cartelle radice di Foto — un operatore può sceglierne alcune nelle Impostazioni.', + 'photo.root_album': '(livello superiore)', + 'photo.n_photos': { + one: '{n} foto', + other: '{n} foto', + }, + 'photo.back': 'Indietro', + 'photo.prev': 'Precedente (←)', + 'photo.next': 'Successiva (→)', + 'photo.zip_album': "Scarica l'album come zip", + 'photo.zoom_in': 'Ingrandisci', + 'photo.zoom_out': 'Riduci', + 'photo.zoom_fit_title': 'Adatta alla finestra', + 'photo.zoom_fit_label': 'Adatta', + 'photo.zoom_100': 'Dimensione reale (100%)', + // LAN cast 'cast.start': 'Trasmetti al dispositivo', 'cast.stop': 'Interrompi trasmissione', @@ -660,8 +679,15 @@ export default { 'settings_node.audio_root_none': '— nessuna scelta —', 'settings_node.audio_root_save': 'Salva', 'settings_node.audio_root_change_confirm': 'Cambiare la radice di Musica sostituisce ciò che ogni membro vede nella scheda Musica. Continuare?', + 'settings_node.photo_roots_title': 'Cartelle radice di Foto', + 'settings_node.photo_roots_hint': "Quali cartelle l'app Foto considera come punti di ingresso per questo gruppo — una libreria fotografica è spesso distribuita su più cartelle, quindi se ne possono scegliere diverse. In Foto non viene mostrato nulla finché non ne viene aggiunta almeno una.", + 'settings_node.photo_roots_none': '— nessuna scelta —', + 'settings_node.photo_roots_add_placeholder': 'Aggiungi una cartella…', + 'settings_node.photo_roots_add': 'Aggiungi', + 'settings_node.photo_roots_remove': 'Rimuovi', + 'settings_node.photo_roots_save': 'Salva', 'settings_node.directories_title': 'Directory', - 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video e Musica usano come proprio punto di ingresso.', + 'settings_node.directories_hint': 'Cartelle condivise, e quale di esse le app Video, Musica e Foto usano come proprio/i punto/i di ingresso.', // Create-group wizard 'wizard.title': 'Crea gruppo', 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 38fb19b..903a902 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -75,6 +75,7 @@ export default { 'group.tab_chat': 'チャット', 'group.tab_video': '動画', 'group.tab_music': '音楽', + 'group.tab_photos': '写真', 'group.tab_members': 'メンバー', 'group.tab_settings': "設定", 'members.danger_leave_hint': "このグループのファイルとチャットにアクセスできなくなります。", @@ -201,6 +202,24 @@ export default { 'music.player_queue': '再生中のキュー', 'music.queue_title': '再生中', + // Photos + 'photo.empty': '写真が見つかりません。', + 'photo.no_roots_configured': 'このグループにはまだ写真のルートフォルダが設定されていません — 操作者が設定画面でいくつか選択できます。', + 'photo.root_album': '(トップレベル)', + 'photo.n_photos': { + one: '{n}枚', + other: '{n}枚', + }, + 'photo.back': '戻る', + 'photo.prev': '前へ(←)', + 'photo.next': '次へ(→)', + 'photo.zip_album': 'アルバムをzipでダウンロード', + 'photo.zoom_in': '拡大', + 'photo.zoom_out': '縮小', + 'photo.zoom_fit_title': 'ウィンドウに合わせる', + 'photo.zoom_fit_label': '合わせる', + 'photo.zoom_100': '実寸(100%)', + // LAN cast 'cast.start': 'デバイスにキャスト', 'cast.stop': 'キャストを停止', @@ -644,8 +663,15 @@ export default { 'settings_node.audio_root_none': '— 未選択 —', 'settings_node.audio_root_save': '保存', 'settings_node.audio_root_change_confirm': '音楽のルートフォルダを変更すると、全メンバーの音楽タブの表示内容が変わります。続行しますか?', + 'settings_node.photo_roots_title': '写真のルートフォルダ', + 'settings_node.photo_roots_hint': 'このグループで写真アプリの起点とするフォルダです。写真ライブラリは複数のフォルダに分かれていることが多いため、複数選択できます。少なくとも1つ追加されるまで、写真には何も表示されません。', + 'settings_node.photo_roots_none': '— 未選択 —', + 'settings_node.photo_roots_add_placeholder': 'フォルダを追加…', + 'settings_node.photo_roots_add': '追加', + 'settings_node.photo_roots_remove': '削除', + 'settings_node.photo_roots_save': '保存', 'settings_node.directories_title': 'ディレクトリ', - 'settings_node.directories_hint': '共有フォルダと、動画アプリ・音楽アプリがそれぞれの起点として使用するフォルダです。', + 'settings_node.directories_hint': '共有フォルダと、動画・音楽・写真の各アプリがそれぞれの起点として使用するフォルダです。', // Wizard 'wizard.title': 'グループを作成', 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 fdfcb89..217aead 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -78,6 +78,7 @@ export default { 'group.tab_chat': 'Chat', 'group.tab_video': "Video's", 'group.tab_music': 'Muziek', + 'group.tab_photos': "Foto's", 'group.tab_members': 'Leden', 'group.tab_settings': "Instellingen", 'members.danger_leave_hint': "U verliest de toegang tot de bestanden en de chat van deze groep.", @@ -205,6 +206,24 @@ export default { 'music.player_queue': 'Huidige wachtrij', 'music.queue_title': 'Nu aan het afspelen', + // Photos + 'photo.empty': "Geen foto's gevonden.", + 'photo.no_roots_configured': "Er zijn nog geen hoofdmappen voor Foto's ingesteld voor deze groep — een operator kan er enkele kiezen bij Instellingen.", + 'photo.root_album': '(hoofdniveau)', + 'photo.n_photos': { + one: '{n} foto', + other: "{n} foto's", + }, + 'photo.back': 'Terug', + 'photo.prev': 'Vorige (←)', + 'photo.next': 'Volgende (→)', + 'photo.zip_album': 'Album downloaden als zip', + 'photo.zoom_in': 'Inzoomen', + 'photo.zoom_out': 'Uitzoomen', + 'photo.zoom_fit_title': 'Passend maken', + 'photo.zoom_fit_label': 'Passend', + 'photo.zoom_100': 'Werkelijke grootte (100%)', + // LAN cast 'cast.start': 'Naar apparaat casten', 'cast.stop': 'Casten stoppen', @@ -662,8 +681,15 @@ export default { 'settings_node.audio_root_none': '— geen gekozen —', 'settings_node.audio_root_save': 'Opslaan', 'settings_node.audio_root_change_confirm': 'Het wijzigen van de hoofdmap voor Muziek vervangt wat elk lid ziet in het tabblad Muziek. Doorgaan?', + 'settings_node.photo_roots_title': "Hoofdmappen voor Foto's", + 'settings_node.photo_roots_hint': "Welke map(pen) de Foto's-app als startpunt gebruikt voor deze groep — een fotobibliotheek is vaak over meerdere mappen verspreid, dus er kunnen er meerdere gekozen worden. Er wordt niets getoond in Foto's totdat er minstens één is toegevoegd.", + 'settings_node.photo_roots_none': '— geen gekozen —', + 'settings_node.photo_roots_add_placeholder': 'Map toevoegen…', + 'settings_node.photo_roots_add': 'Toevoegen', + 'settings_node.photo_roots_remove': 'Verwijderen', + 'settings_node.photo_roots_save': 'Opslaan', 'settings_node.directories_title': 'Mappen', - 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s- en Muziek-apps als eigen startpunt gebruiken.', + 'settings_node.directories_hint': 'Gedeelde mappen, en welke daarvan de Video\'s-, Muziek- en Foto\'s-apps als eigen startpunt(en) gebruiken.', // Create group wizard 'wizard.title': 'Groep aanmaken', 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 8e7f636..25d67f9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -82,6 +82,7 @@ export default { 'group.tab_chat': 'Czat', 'group.tab_video': 'Wideo', 'group.tab_music': 'Muzyka', + 'group.tab_photos': 'Zdjęcia', 'group.tab_members': 'Członkowie', 'group.tab_settings': "Ustawienia", 'members.danger_leave_hint': "Utracisz dostęp do plików i czatu tej grupy.", @@ -214,6 +215,26 @@ export default { 'music.player_queue': 'Aktualna kolejka', 'music.queue_title': 'Teraz odtwarzane', + // Photos + 'photo.empty': 'Nie znaleziono żadnych zdjęć.', + 'photo.no_roots_configured': 'Dla tej grupy nie wybrano jeszcze katalogów głównych Zdjęć — operator może wybrać kilka w Ustawieniach.', + 'photo.root_album': '(poziom główny)', + 'photo.n_photos': { + one: '{n} zdjęcie', + few: '{n} zdjęcia', + many: '{n} zdjęć', + other: '{n} zdjęcia', + }, + 'photo.back': 'Wstecz', + 'photo.prev': 'Poprzednie (←)', + 'photo.next': 'Następne (→)', + 'photo.zip_album': 'Pobierz album jako zip', + 'photo.zoom_in': 'Powiększ', + 'photo.zoom_out': 'Pomniejsz', + 'photo.zoom_fit_title': 'Dopasuj do okna', + 'photo.zoom_fit_label': 'Dopasuj', + 'photo.zoom_100': 'Rzeczywisty rozmiar (100%)', + // LAN cast 'cast.start': 'Przesyłaj na urządzenie', 'cast.stop': 'Zatrzymaj przesyłanie', @@ -687,8 +708,15 @@ export default { 'settings_node.audio_root_none': '— nie wybrano —', 'settings_node.audio_root_save': 'Zapisz', 'settings_node.audio_root_change_confirm': 'Zmiana katalogu głównego Muzyki zastępuje to, co widzi każdy członek w karcie Muzyka. Kontynuować?', + 'settings_node.photo_roots_title': 'Katalogi główne Zdjęć', + 'settings_node.photo_roots_hint': 'Które katalogi aplikacja Zdjęcia traktuje jako punkty wejścia dla tej grupy — biblioteka zdjęć jest często rozproszona w wielu katalogach, więc można wybrać kilka. W Zdjęciach nic się nie wyświetla, dopóki nie zostanie dodany co najmniej jeden.', + 'settings_node.photo_roots_none': '— nie wybrano —', + 'settings_node.photo_roots_add_placeholder': 'Dodaj katalog…', + 'settings_node.photo_roots_add': 'Dodaj', + 'settings_node.photo_roots_remove': 'Usuń', + 'settings_node.photo_roots_save': 'Zapisz', 'settings_node.directories_title': 'Katalogi', - 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo i Muzyka traktują jako własny punkt wejścia.', + 'settings_node.directories_hint': 'Katalogi udostępnione oraz to, który z nich aplikacje Wideo, Muzyka i Zdjęcia traktują jako własny punkt (punkty) wejścia.', // Create-group wizard 'wizard.title': 'Utwórz grupę', 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 97786f4..c32ff63 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 @@ -78,6 +78,7 @@ export default { 'group.tab_chat': 'Conversa', 'group.tab_video': 'Vídeos', 'group.tab_music': 'Música', + 'group.tab_photos': 'Fotos', 'group.tab_members': 'Membros', 'group.tab_settings': "Configurações", 'members.danger_leave_hint': "Você perderá o acesso aos arquivos e ao chat deste grupo.", @@ -205,6 +206,24 @@ export default { 'music.player_queue': 'Fila atual', 'music.queue_title': 'Tocando agora', + // Photos + 'photo.empty': 'Nenhuma foto encontrada.', + 'photo.no_roots_configured': 'Ainda não há pastas raiz de Fotos definidas para este grupo — um operador pode escolher algumas em Configurações.', + 'photo.root_album': '(nível superior)', + 'photo.n_photos': { + one: '{n} foto', + other: '{n} fotos', + }, + 'photo.back': 'Voltar', + 'photo.prev': 'Anterior (←)', + 'photo.next': 'Próxima (→)', + 'photo.zip_album': 'Baixar álbum como zip', + 'photo.zoom_in': 'Aumentar zoom', + 'photo.zoom_out': 'Diminuir zoom', + 'photo.zoom_fit_title': 'Ajustar à janela', + 'photo.zoom_fit_label': 'Ajustar', + 'photo.zoom_100': 'Tamanho real (100%)', + // LAN cast 'cast.start': 'Transmitir para dispositivo', 'cast.stop': 'Parar transmissão', @@ -647,8 +666,15 @@ export default { 'settings_node.audio_root_none': '— nenhuma escolhida —', 'settings_node.audio_root_save': 'Salvar', 'settings_node.audio_root_change_confirm': 'Alterar a raiz de Música substitui o que cada membro vê na aba Música. Continuar?', + 'settings_node.photo_roots_title': 'Pastas raiz de Fotos', + 'settings_node.photo_roots_hint': 'Quais pastas o app Fotos trata como pontos de entrada para este grupo — uma biblioteca de fotos costuma estar espalhada em várias pastas, então mais de uma pode ser escolhida. Nada é exibido em Fotos até que ao menos uma seja adicionada.', + 'settings_node.photo_roots_none': '— nenhuma escolhida —', + 'settings_node.photo_roots_add_placeholder': 'Adicionar uma pasta…', + 'settings_node.photo_roots_add': 'Adicionar', + 'settings_node.photo_roots_remove': 'Remover', + 'settings_node.photo_roots_save': 'Salvar', 'settings_node.directories_title': 'Diretórios', - 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos e Música tratam como seu próprio ponto de entrada.', + 'settings_node.directories_hint': 'Pastas compartilhadas, e qual delas os apps Vídeos, Música e Fotos tratam como seu(s) próprio(s) ponto(s) de entrada.', // Create group wizard 'wizard.title': 'Criar grupo', 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 a26062d..ee0efdf 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 @@ -75,6 +75,7 @@ export default { 'group.tab_chat': '聊天', 'group.tab_video': '视频', 'group.tab_music': '音乐', + 'group.tab_photos': '照片', 'group.tab_members': '成员', 'group.tab_settings': "设置", 'members.danger_leave_hint': "您将无法再访问该群组的文件和聊天。", @@ -198,6 +199,24 @@ export default { 'music.player_queue': '当前队列', 'music.queue_title': '正在播放', + // Photos + 'photo.empty': '未找到照片。', + 'photo.no_roots_configured': '此群组尚未设置照片根目录 — 操作员可以在设置中选择一些。', + 'photo.root_album': '(顶层)', + 'photo.n_photos': { + one: '{n} 张照片', + other: '{n} 张照片', + }, + 'photo.back': '返回', + 'photo.prev': '上一张(←)', + 'photo.next': '下一张(→)', + 'photo.zip_album': '将相册下载为 zip', + 'photo.zoom_in': '放大', + 'photo.zoom_out': '缩小', + 'photo.zoom_fit_title': '适应窗口', + 'photo.zoom_fit_label': '适应', + 'photo.zoom_100': '实际大小(100%)', + // LAN cast 'cast.start': '投射到设备', 'cast.stop': '停止投射', @@ -630,8 +649,15 @@ export default { 'settings_node.audio_root_none': '— 未选择 —', 'settings_node.audio_root_save': '保存', 'settings_node.audio_root_change_confirm': '更改音乐根目录会替换每位成员在“音乐”标签页中看到的内容。是否继续?', + 'settings_node.photo_roots_title': '照片根目录', + 'settings_node.photo_roots_hint': '该群组“照片”应用的入口文件夹 — 照片库通常分散在多个文件夹中,因此可以选择多个。在添加至少一个之前,“照片”中不会显示任何内容。', + 'settings_node.photo_roots_none': '— 未选择 —', + 'settings_node.photo_roots_add_placeholder': '添加文件夹…', + 'settings_node.photo_roots_add': '添加', + 'settings_node.photo_roots_remove': '移除', + 'settings_node.photo_roots_save': '保存', 'settings_node.directories_title': '目录', - 'settings_node.directories_hint': '共享文件夹,以及“视频”和“音乐”应用各自使用哪个作为入口。', + 'settings_node.directories_hint': '共享文件夹,以及“视频”“音乐”和“照片”应用各自使用哪个(些)作为入口。', // Create group wizard 'wizard.title': '创建群组', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js new file mode 100644 index 0000000..26785e7 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -0,0 +1,370 @@ +import { + html, useState, useEffect, useMemo, useCallback, useRef, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { + formatSize, CHUNK_SIZE, pipelinedDownload, downloadDirectory, +} from './file-utils.js'; +import { transfers } from './transfers.js'; +import { MediaThumb, LazyTile } from './video-app.js'; + +// ── Photos ─────────────────────────────────────────────────────────────────── +// +// docs/photos.md. Unlike Videos/Music: several root folders per group +// (photoRoots is a list, §2.1), one album-grid view with no mode toggle and +// no third-party matching step (§2.3), and per-photo info read from the +// file's own EXIF at index time rather than fetched live. Every directory +// containing at least one image under a configured root is one album card; +// opening one shows its photos in a grid with a lightbox (next/previous, +// keyboard arrows, EXIF info when present) and a "zip this album" button +// that reuses Files' own zip mechanism unchanged (file-utils.js's +// downloadDirectory, lifted out of files-app.js for exactly this reuse). + +function underAnyPhotoRoot(entry, photoRoots) { + const p = entry.path || ''; + return (photoRoots || []).some((r) => p === r || p.startsWith(r + '/')); +} + +function groupPhotoAlbums(entries, photoRoots) { + const byDir = new Map(); + for (const e of entries) { + if (e.type !== 'image' || !underAnyPhotoRoot(e, photoRoots)) continue; + // e.path is already the file's containing directory, not the full + // path+filename (files-app.js's own convention, also relied on by + // zipstream.js's entriesUnder) — it must not be stripped a second time, + // or every album collapses one level up into its parent (found live: + // a "backup" root with several subfolders showed as a single "backup" + // album holding everything, because this line was extracting the + // dirname of a value that was already a dirname). + const dir = e.path || ''; + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(e); + } + return [...byDir.entries()] + .map(([dir, photos]) => ({ + dir, photos: photos.sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.dir.localeCompare(b.dir)); +} + +// Underscores replaced with spaces for display only — this never touches +// the folder on disk or anything sent to the node, purely how the name +// reads in the grid/heading (a raw "mariage_joce" reads worse than +// "mariage joce" for something meant to look like an album, not a filename). +function albumTitle(dir) { + return dir ? dir.split('/').pop().replace(/_/g, ' ') : t('photo.root_album'); +} + +function formatTakenAt(ts) { + if (!ts) return ''; + return new Date(ts * 1000).toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }); +} + +// A single year when every dated photo in the album agrees, a range when +// they don't (an album spanning New Year's Eve, or just a loosely-sorted +// folder) — never guessed for photos with no EXIF date at all, which just +// don't count toward it. +function albumYearLabel(photos) { + const years = [...new Set( + photos.filter((p) => p.taken_at).map((p) => new Date(p.taken_at * 1000).getFullYear()), + )].sort((a, b) => a - b); + if (years.length === 0) return ''; + if (years.length === 1) return String(years[0]); + return `${years[0]}–${years[years.length - 1]}`; +} + +// ── 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 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} + 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> + </div> + </div> + `; +} + +function AlbumLanding({ albums, transportRef, gekRef, onOpen }) { + return html` + <div class="photo-album-grid"> + ${albums.map((a) => html` + <${LazyTile} key=${a.dir} cls="photo-album-tile-slot"> + <${AlbumCard} album=${a} transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => onOpen(a.dir)} /> + </${LazyTile}> + `)} + </div> + `; +} + +// ── open album: grid of its own photos ────────────────────────────────────── + +function PhotoTile({ entry, transportRef, gekRef, onOpen }) { + return html` + <div class="photo-tile" onClick=${onOpen}> + <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} + cls="photo-tile-thumb" transportRef=${transportRef} gekRef=${gekRef} + emptyIcon="image" /> + </div> + `; +} + +// ── lightbox: full image, next/previous, per-photo info, zoom ────────────── +// +// Cached per session by file id, same shape as video-app.js's +// _thumbBlobCache — clicking back and forth between two photos decrypts +// each once, not once per visit. +const _fullBlobCache = new Map(); + +// Zoom is only ever meaningful for the lightbox's own full-resolution +// image — nowhere else in the app shows one, so there is nothing to gate +// this behind beyond the component itself only ever being mounted for a +// photo. +const ZOOM_STEP = 25; +const ZOOM_MIN = 25; +const ZOOM_MAX = 400; + +function Lightbox({ photos, index, transportRef, gekRef, onClose, onNav }) { + const entry = photos[index]; + 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 + // an explicit percentage of the image's own natural size, read off the + // loaded <img> itself rather than trusted from EXIF — accurate whether or + // not enrichment ever ran, and already EXIF-orientation-corrected the + // same way the browser renders the <img> itself. + const [zoomPercent, setZoomPercent] = useState(null); + const [naturalSize, setNaturalSize] = useState(null); + const slotRef = useRef(null); + + useEffect(() => { + const cached = _fullBlobCache.get(entry.id); + if (cached) { setBlobUrl(cached); setLoading(false); return; } + setBlobUrl(null); + setLoading(true); + let cancelled = false; + (async () => { + const transport = transportRef.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); + if (cancelled) return; + const url = URL.createObjectURL(new Blob(chunks)); + _fullBlobCache.set(entry.id, url); + setBlobUrl(url); + } catch { + /* leave the placeholder — a transient fetch failure isn't fatal, next/close still work */ + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [entry.id]); + + // Every photo opens fit-to-window, same as any other viewer — a zoom + // level chosen for one picture saying nothing about the next. + useEffect(() => { setZoomPercent(null); setNaturalSize(null); }, [entry.id]); + + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowLeft' && index > 0) onNav(-1); + else if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, onNav, index, photos.length]); + + const handleImgLoad = (e) => { + setNaturalSize({ w: e.target.naturalWidth, h: e.target.naturalHeight }); + }; + + // The percentage "fit to window" actually renders at, so the first zoom + // step moves from *there* rather than silently snapping to 100% first — + // object-fit: contain never upscales past the image's own natural size + // (nothing here sets width/height:100% to force it to), so fit is never + // above 100% either. + const fitPercent = () => { + if (!naturalSize || !slotRef.current) return 100; + const rect = slotRef.current.getBoundingClientRect(); + return Math.min(1, rect.width / naturalSize.w, rect.height / naturalSize.h) * 100; + }; + const zoomIn = () => setZoomPercent( + (z) => Math.min(ZOOM_MAX, Math.round(z ?? fitPercent()) + ZOOM_STEP)); + const zoomOut = () => setZoomPercent( + (z) => Math.max(ZOOM_MIN, Math.round(z ?? fitPercent()) - ZOOM_STEP)); + const zoomFit = () => setZoomPercent(null); + const zoomActual = () => setZoomPercent(100); + + const zoomed = zoomPercent != null; + const imgStyle = zoomed && naturalSize + ? `width:${Math.round(naturalSize.w * zoomPercent / 100)}px; ` + + `height:${Math.round(naturalSize.h * zoomPercent / 100)}px;` + : ''; + + return html` + <div class="video-overlay photo-lightbox" onClick=${(e) => { + if (e.target.classList.contains('photo-lightbox')) onClose(); + }}> + <div class="video-top-bar"> + <span class="video-title">${entry.name}</span> + <div class="photo-zoom-controls"> + <button class="photo-icon-btn" disabled=${!blobUrl || zoomPercent === ZOOM_MIN} + onClick=${zoomOut} title=${t('photo.zoom_out')}> + <${Icon} name="zoom-out" cls="photo-icon-btn-icon" /></button> + <span class="photo-zoom-percent"> + ${zoomed ? `${zoomPercent}%` : t('photo.zoom_fit_label')}</span> + <button class="photo-icon-btn" disabled=${!blobUrl || zoomPercent === ZOOM_MAX} + onClick=${zoomIn} title=${t('photo.zoom_in')}> + <${Icon} name="zoom-in" cls="photo-icon-btn-icon" /></button> + <button class="photo-icon-btn ${!zoomed ? 'active' : ''}" disabled=${!blobUrl} + onClick=${zoomFit} title=${t('photo.zoom_fit_title')}> + <${Icon} name="frame" cls="photo-icon-btn-icon" /></button> + <button class="photo-icon-btn photo-icon-btn-text ${zoomPercent === 100 ? 'active' : ''}" + disabled=${!blobUrl} onClick=${zoomActual} title=${t('photo.zoom_100')}>100%</button> + </div> + <button class="video-close" onClick=${onClose} title=${t('video.close')}> + <${Icon} name="close" /></button> + </div> + <div class="photo-lightbox-body"> + <button class="photo-nav photo-nav-prev" disabled=${index === 0} + onClick=${() => onNav(-1)} title=${t('photo.prev')}> + <${Icon} name="chevron" cls="photo-nav-icon photo-nav-prev-icon" /></button> + <div ref=${slotRef} class="photo-lightbox-image-slot ${zoomed ? 'zoomed' : ''}"> + ${loading && html`<span class="spinner"></span>`} + ${blobUrl && html`<img class="photo-lightbox-image ${zoomed ? 'zoomed' : ''}" + style=${imgStyle} src=${blobUrl} alt=${entry.name} onLoad=${handleImgLoad} />`} + </div> + <button class="photo-nav photo-nav-next" disabled=${index === photos.length - 1} + onClick=${() => onNav(1)} title=${t('photo.next')}> + <${Icon} name="chevron" cls="photo-nav-icon photo-nav-next-icon" /></button> + </div> + <div class="photo-lightbox-info"> + ${entry.width && entry.height && html`<span>${entry.width}×${entry.height}</span>`} + <span>${formatSize(entry.size)}</span> + ${entry.taken_at && html`<span>${formatTakenAt(entry.taken_at)}</span>`} + ${entry.camera && html`<span>${entry.camera}</span>`} + <span class="photo-lightbox-count">${index + 1} / ${photos.length}</span> + </div> + </div> + `; +} + +function AlbumView({ album, entries, transportRef, gekRef, setError, onBack }) { + const [lightboxIndex, setLightboxIndex] = useState(null); + + const zip = useCallback(async () => { + const transport = transportRef.current; + await downloadDirectory( + transfers, transport, gekRef.current, entries, album.dir, { setError }); + }, [entries, album.dir]); + + const navigate = useCallback((delta) => { + setLightboxIndex((i) => { + const next = i + delta; + return next >= 0 && next < album.photos.length ? next : i; + }); + }, [album.photos.length]); + + const year = albumYearLabel(album.photos); + + return html` + <div class="photo-album-bar"> + <div class="photo-album-heading"> + <button class="photo-icon-btn" onClick=${onBack} title=${t('photo.back')}> + <${Icon} name="chevron" cls="photo-icon-btn-icon photo-back-icon" /></button> + <div class="photo-album-heading-text"> + <span class="photo-album-heading-title">${albumTitle(album.dir)}</span> + ${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> + </div> + <div class="photo-grid"> + ${album.photos.map((e, i) => html` + <${LazyTile} key=${e.id} cls="photo-tile-slot"> + <${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => setLightboxIndex(i)} /> + </${LazyTile}> + `)} + </div> + ${lightboxIndex !== null && html` + <${Lightbox} photos=${album.photos} index=${lightboxIndex} + transportRef=${transportRef} gekRef=${gekRef} + onClose=${() => setLightboxIndex(null)} onNav=${navigate} /> + `} + `; +} + +// ── shell ──────────────────────────────────────────────────────────────────── + +function PhotosApp({ + groupId, transportRef, gekRef, status, entries, photoRoots, setError, +}) { + const [openDir, setOpenDir] = useState(null); + const [filter, setFilter] = useState(''); + + useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]); + + const albums = useMemo( + () => groupPhotoAlbums(entries, photoRoots), [entries, photoRoots]); + + const needle = filter.trim().toLowerCase(); + const filteredAlbums = useMemo(() => (!needle ? albums : albums.filter( + (a) => albumTitle(a.dir).toLowerCase().includes(needle))), [albums, needle]); + + const openAlbum = openDir != null ? albums.find((a) => a.dir === openDir) : null; + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` + <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> + `} + ${status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} + ${status === 'connected' && (!photoRoots || photoRoots.length === 0) && html` + <p class="page-message">${t('photo.no_roots_configured')}</p> + `} + ${status === 'connected' && photoRoots && photoRoots.length > 0 && !openAlbum && html` + <div class="photo-toolbar"> + <div class="tb-search"> + <${Icon} name="search" /> + <input type="text" placeholder="${t('group.filter')}" + value=${filter} onInput=${(e) => setFilter(e.target.value)} /> + </div> + </div> + ${filteredAlbums.length === 0 && html` + <p class="page-message">${needle ? t('group.empty_filter') : t('photo.empty')}</p> + `} + <${AlbumLanding} albums=${filteredAlbums} + transportRef=${transportRef} gekRef=${gekRef} + onOpen=${(dir) => setOpenDir(dir)} /> + `} + ${status === 'connected' && openAlbum && html` + <${AlbumView} album=${openAlbum} entries=${entries} + transportRef=${transportRef} gekRef=${gekRef} setError=${setError} + onBack=${() => setOpenDir(null)} /> + `} + `; +} + +export { PhotosApp }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index b332f6f..9a4fb5a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1125,6 +1125,20 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } margin: 0; } +/* Photos app's root add/remove list (docs/photos.md §2.2) — a set, unlike + the Videos/Music single-value picker above. */ +.settings-root-list { list-style: none; margin: 4px 0 8px; padding: 0; } +.settings-root-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 0; + font-size: 0.88em; +} +.settings-root-list-item .link-btn { padding: 2px; } +.settings-root-list-item .icon { width: 14px; height: 14px; } + .settings-select { padding: 6px 10px; border: 1px solid var(--border); @@ -3098,3 +3112,266 @@ a.transfer-name { .music-player-seek { order: 4; flex-basis: 100%; } .music-player-volume { display: none; } } + +/* ── Photos app (photos-app.js, docs/photos.md) ───────────────────────────── */ + +.photo-toolbar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; +} +.photo-toolbar .tb-search { margin-left: auto; } + +/* Open-album title bar: name + year on the left, two big monochrome + icon-only actions (back, zip) on the right — same row, same size as a + group tab's own icon (.tab-icon, 22px) so they read as "app-level" + controls rather than small inline buttons. */ +.photo-album-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} +.photo-album-heading { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.photo-album-heading-text { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} +.photo-album-heading-title { + font-weight: 600; + font-size: 1.15em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.photo-album-heading-year { font-size: 0.85em; color: var(--text-dim); flex-shrink: 0; } + +.photo-icon-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + padding: 7px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} +.photo-icon-btn:hover { color: var(--text); background: var(--bg-surface); } +.photo-icon-btn-icon { width: 22px; height: 22px; } + +/* .tb-btn's own chevron is drawn pointing down; rotated here to read as + "back" (left) without a second icon in icon.js. */ +.photo-back-icon { transform: rotate(90deg); } + +/* Landing view — one card per album (a directory containing images) */ + +.photo-album-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} +.photo-album-tile-slot { min-height: 190px; } + +.photo-album-card { + cursor: pointer; + border-radius: 8px; + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border); + transition: border-color 0.12s, transform 0.12s; +} +.photo-album-card:hover { border-color: var(--accent); transform: translateY(-2px); } + +.photo-album-cover { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--bg-surface); +} +/* MediaThumb (video-app.js) always adds its own .video-thumb-empty class + for the placeholder state, whatever `cls` is passed — that global rule + already centers the icon; only the square sizing above is Photos-specific. */ +.photo-album-cover.video-thumb-empty .icon, +.photo-tile-thumb.video-thumb-empty .icon { width: 24px; height: 24px; } + +.photo-album-info { padding: 8px 10px; } +.photo-album-title { + font-size: 0.86em; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.photo-album-sub { font-size: 0.75em; color: var(--text-dim); margin-top: 2px; } + +/* Open-album view — a grid of the album's own photos */ + +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; +} +.photo-tile-slot { min-height: 110px; } +.photo-tile { + cursor: pointer; + border-radius: 6px; + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border); +} +.photo-tile:hover { border-color: var(--accent); } +.photo-tile-thumb { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--bg-surface); +} + +/* Lightbox — sits inside the shared .video-overlay/.video-top-bar/.video-title/ + .video-close, same as music-app.js's detail modal reuses them. */ + +/* Scoped to .photo-lightbox specifically — .video-top-bar itself stays + transparent everywhere else that reuses it (VideoPlayer, the video/music + detail modals), where nothing zooms underneath it. */ +.photo-lightbox .video-top-bar { + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent); + padding-bottom: 24px; +} + +.photo-lightbox-body { + /* Bounded strictly between the top bar and the info strip, rather than + the full-viewport height it used to take — a zoomed-in image, larger + than the window, painted straight through that space (nothing ever + reserved it) and sat visually behind the zoom controls, which had only + a faint translucent highlight of their own to read against a bright, + busy photo. Positioning the image area below the bar's own real, + opaque strip (below) is what actually keeps them apart, not z-index — + the buttons were never behind the image in stacking order, just hard + to see in front of it. */ + position: absolute; + top: 60px; + bottom: 44px; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 0 8px; +} +.photo-nav { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #e2e8f0; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} +.photo-nav:hover { background: rgba(255, 255, 255, 0.22); } +.photo-nav:disabled { opacity: 0.3; cursor: default; } +.photo-nav-icon { width: 20px; height: 20px; } +.photo-nav-prev-icon { transform: rotate(90deg); } +.photo-nav-next-icon { transform: rotate(-90deg); } + +.photo-lightbox-image-slot { + flex: 1; + min-width: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + /* A zoomed-in image is routinely larger than the viewport — this is what + makes it pannable instead of just clipped. Harmless when the image + fits (fit-to-window, or zoomed below 100%): no scrollbar appears. */ + overflow: auto; +} +/* flex centering (align-items/justify-content: center) plus overflow:auto + is a well-known trap: the browser centers overflowing content by + shifting it, but the "leading" half of that overflow — here, the top of + a zoomed-in photo — sits outside the range the scrollport actually + exposes, so it can never be scrolled into view at all. Reported live as + "unusable" (the top of the photo was gone, with no way to reach it). + Switching to top/left alignment for the zoomed state fixes exactly that: + the top-left corner is always where the image starts, and scrolling + down/right reaches the rest — the same convention most image viewers + use once you're past fit-to-window anyway. */ +.photo-lightbox-image-slot.zoomed { + align-items: flex-start; + justify-content: flex-start; +} +.photo-lightbox-image { + /* Relative to the now-properly-bounded slot (.photo-lightbox-body's + top/bottom insets), not a guessed viewport fraction — correct at any + window size without re-tuning a magic number. */ + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +/* An explicit pixel size (photos-app.js's imgStyle) replaces the + fit-to-window constraints above — object-fit has nothing left to do + once both dimensions are set directly. */ +.photo-lightbox-image.zoomed { + max-width: none; + max-height: none; + object-fit: initial; + display: block; +} + +/* Zoom controls sit in the dark lightbox chrome (.video-top-bar), not the + app's light Settings/toolbar chrome — .photo-icon-btn's own colors + (meant for the light chrome elsewhere in Photos) are overridden here to + match .video-close's existing treatment instead. */ +.photo-zoom-controls { display: flex; align-items: center; gap: 2px; } +.photo-zoom-controls .photo-icon-btn { + color: #e2e8f0; + padding: 5px; + border-radius: 4px; +} +.photo-zoom-controls .photo-icon-btn:hover { background: rgba(255, 255, 255, 0.15); } +.photo-zoom-controls .photo-icon-btn.active { background: rgba(255, 255, 255, 0.18); } +.photo-zoom-controls .photo-icon-btn:disabled { opacity: 0.35; cursor: default; } +.photo-zoom-controls .photo-icon-btn:disabled:hover { background: none; } +.photo-zoom-controls .photo-icon-btn-icon { width: 18px; height: 18px; } +.photo-icon-btn-text { font-size: 0.72em; font-weight: 600; padding: 5px 8px; } +.photo-zoom-percent { + font-size: 0.75em; + color: #94a3b8; + min-width: 38px; + text-align: center; + flex-shrink: 0; +} + +.photo-lightbox-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + flex-wrap: wrap; + padding: 10px 20px; + color: #cbd5e1; + font-size: 0.78em; + background: linear-gradient(to top, rgba(0, 0, 0, 0.55), transparent); +} +.photo-lightbox-count { color: #94a3b8; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 7528a92..065ccc0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -74,6 +74,7 @@ function _aborted() { // one. const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', + 'photo_roots', 'musicbrainz_config', 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach', @@ -133,6 +134,7 @@ class MeshBayTransport { set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onVideoRoot(fn) { this._onVideoRoot = fn; } set onAudioRoot(fn) { this._onAudioRoot = fn; } + set onPhotoRoots(fn) { this._onPhotoRoots = fn; } set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } @@ -645,6 +647,27 @@ class MeshBayTransport { } /** + * Which folder(s) the Photos app treats as its entry points for this + * group (docs/photos.md §2.1). Unlike setVideoRoot/setAudioRoot, `roots` + * is a whole set, replaced in one signed op — same shape as + * setAppsEnabled. The client normalizes the same way the node does + * (webrtc_server.py's `_do_photo_roots`: trim slashes, drop empties, + * dedupe, sort) so the subject built here matches byte-for-byte what the + * node signs the challenge against. + */ + async setPhotoRoots(roots, signFn) { + const clean = [...new Set( + (roots || []).map((r) => (r || '').replace(/^\/+|\/+$/g, '')).filter(Boolean), + )].sort(); + const msg = await this._sendAndWait({ type: 'photo_roots', v: '0.11', roots: clean }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'photo_roots', clean.join(','), signFn); + } + return msg; + } + + /** * MusicBrainz metadata for one track's path (Music app, docs/musicbay.md * §4.3) — same shape as fetchMediaMeta, minus a season/episode concept: * album-level (release), resolved from the track's own artist/album @@ -1645,6 +1668,12 @@ class MeshBayTransport { this._onAudioRoot(msg.path || ''); } + // Same shape: the operator replaced the Photos app's whole root set + // for this group (docs/photos.md §2.1). + if (msg.type === 'photo_roots_ack' && this._onPhotoRoots) { + this._onPhotoRoots(msg.roots || []); + } + // Node-wide, like tmdb_config_ack above — no token equivalent to hide, // only whether a contact string is configured (docs/musicbay.md §3.2). if (msg.type === 'musicbrainz_config_ack' && this._onMusicbrainzConfig) { |