diff options
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.js | 370 |
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 }; |