aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/photos-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app.js370
1 files changed, 370 insertions, 0 deletions
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 };