diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-25 11:46:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-25 11:46:17 +0200 |
| commit | 2fcdd07d1e5d331ad02b723f1c45603a0989c264 (patch) | |
| tree | f606f01f5492648824876efe4c8a431d9b3a59d6 /packages/meshbay-hub/src | |
| parent | d427118bd91d67f1a041e5daf267aebcd34ca9d7 (diff) | |
| download | meshbay-2fcdd07d1e5d331ad02b723f1c45603a0989c264.tar.gz | |
feat: add Photos group app
A new group application (docs/apps.md's plug-in mechanism), following the
plan in docs/photos.md. Unlike Videos/Music: several photo roots per group
instead of one (photo_roots is a set, one signed op replaces it whole),
a single album-grid view with no third-party matching step, and per-photo
info read from the file's own EXIF at index time — no metadata service,
no credential, no outbound network call at all.
Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera`
on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`.
Node: roster.py stores photo_roots as a group_settings entry (JSON list,
same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the
whole set in one op, same pattern as apps_enabled; a new PhotoEnricher
(indexer/enrich_photo.py) runs Pillow in its own small bounded pool,
separate from the video/audio pools, producing a resized thumbnail plus
the two EXIF fields — never GPS, checked by a grep-based regression test.
Client: photos-app.js — one album card per directory containing images,
a per-album photo grid, and a lightbox with next/previous (keyboard and
buttons), zoom in/out/fit/100% starting from the actual on-screen fit
percentage, and a "zip this album" button reusing files-app.js's own zip
mechanism (lifted into file-utils.js's downloadDirectory so both call the
same implementation). group-settings.js gets an add/remove multi-root
picker, distinct from Videos/Music's single-value one.
Bugs found and fixed before this ever shipped, worth keeping the story of:
- enrich_photo.py read width/height from the raw image *before* applying
EXIF orientation correction, and read DateTimeOriginal off the plain
0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which
Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict
round-trips through Pillow either way, which is exactly what would have
hidden both bugs; the regression test builds EXIF with piexif instead,
matching what real hardware produces.
- photos-app.js's album grouping stripped a trailing path segment from
entry.path under the assumption it still carried a filename — it
doesn't (files-app.js's own convention: e.path is already the
containing directory), so every album collapsed one level into its
parent. Found live against a real multi-folder library.
- transport.js's ADMIN_OP_TYPES allowlist (already the fix for an
identical bug on video_root/apps_enabled, see 4783d81) was missing
photo_roots: its admin_challenge matched no pending request and was
silently dropped, so saving a photo root just timed out after 30s with
no error.
- daemon.py pruned a thumbnail when its file left the index (root removed
or reconfigured) but never forgot the content hash was "already
attempted" — the same bytes reappearing under a renamed/relocated root
(an operator's real workflow) were then permanently skipped, forever,
with nothing to indicate why. Discarding the attempt alongside the
cache entry on prune is what makes pruning actually reversible.
- packages/meshbay-client's app:// protocol handler served every file
with no Cache-Control header, so Chromium was free to serve a stale
cached copy indefinitely — none of several `npm run sync-ui` + reload
cycles during development actually picked up the new code until the
renderer's disk cache was cleared by hand. Now sends Cache-Control:
no-store.
- the lightbox's zoomed image used flex centering (align-items/
justify-content: center) combined with overflow: auto — a well-known
trap where the browser centers overflowing content by shifting it, and
the leading half of that overflow (here, the top of a zoomed photo)
sits outside what the scrollport can actually reach. Reported live as
"unusable". Fixed by switching to top/left alignment once zoomed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
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) { |