summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/apps.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js89
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js114
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/icon.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js370
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css277
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js29
19 files changed, 1178 insertions, 94 deletions
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) {