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'; import { SourceTag } from './group-name.js'; // ── Photos ─────────────────────────────────────────────────────────────────── // // docs/MESHBAY_DESIGN.md §9.9. 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 }) { const cover = album.photos.find((p) => p.thumb_hash) || album.photos[0]; const tRef = cover._tRef || transportRef; const gRef = cover._gRef || gekRef; const year = albumYearLabel(album.photos); return html`
<${MediaThumb} thumbHash=${cover.thumb_hash} alt=${albumTitle(album.dir)} cls="photo-album-cover" transportRef=${tRef} gekRef=${gRef} emptyIcon="image" />
${albumTitle(album.dir)}
${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })}
<${SourceTag} entries=${album.photos} cls="photo-card-group" />
`; } function AlbumLanding({ albums, transportRef, gekRef, onOpen }) { return html`
${albums.map((a) => html` <${LazyTile} key=${a.dir} cls="photo-album-tile-slot"> <${AlbumCard} album=${a} transportRef=${transportRef} gekRef=${gekRef} onOpen=${() => onOpen(a.dir)} /> `)}
`; } // ── open album: grid of its own photos ────────────────────────────────────── function PhotoTile({ entry, transportRef, gekRef, onOpen }) { const tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; return html`
<${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.name} cls="photo-tile-thumb" transportRef=${tRef} gekRef=${gRef} emptyIcon="image" />
`; } // ── 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 tRef = entry._tRef || transportRef; const gRef = entry._gRef || gekRef; 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 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 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 = tRef.current; if (!transport || !transport.connected) { setLoading(false); return; } try { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); const chunks = await pipelinedDownload( transport, gRef.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`
{ if (e.target.classList.contains('photo-lightbox')) onClose(); }}>
${entry.name}
${zoomed ? `${zoomPercent}%` : t('photo.zoom_fit_label')}
${loading && html``} ${blobUrl && html`${entry.name}`}
${entry.width && entry.height && html`${entry.width}×${entry.height}`} ${formatSize(entry.size)} ${entry.taken_at && html`${formatTakenAt(entry.taken_at)}`} ${entry.camera && html`${entry.camera}`} ${index + 1} / ${photos.length}
`; } function AlbumView({ album, entries, transportRef, gekRef, setError, onBack, readOnly }) { 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`
${albumTitle(album.dir)} ${year && html`${year}`}
${!readOnly && html``}
${album.photos.map((e, i) => html` <${LazyTile} key=${e.id} cls="photo-tile-slot"> <${PhotoTile} entry=${e} transportRef=${transportRef} gekRef=${gekRef} onOpen=${() => setLightboxIndex(i)} /> `)}
${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, availableEntries, photoDirectories, setError, hideFilter, readOnly, }) { const [openDir, setOpenDir] = useState(null); const [filter, setFilter] = useState(''); useEffect(() => { setOpenDir(null); setFilter(''); }, [groupId]); const photoEntries = availableEntries || entries; const albums = useMemo( () => groupPhotoAlbums(photoEntries, photoDirectories), [photoEntries, photoDirectories]); 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`

${' '}${t('status.connecting_short')}

`} ${status === 'offline' && html`

${t('group.offline_title')} ${t('group.offline_hint')}

`} ${status === 'connected' && (photoDirectories || []).length === 0 && html`

${t('photo.no_roots_configured')}

`} ${status === 'connected' && (photoDirectories || []).length > 0 && !openAlbum && html` ${/* The filter is the only thing in it, so under `hideFilter` there is no toolbar rather than an empty one — an empty band still pins, and would hold a strip of the page open under the search field for nothing. */ !hideFilter && html`
`} ${filteredAlbums.length === 0 && html`

${needle ? t('group.empty_filter') : t('photo.empty')}

`} <${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)} readOnly=${readOnly} /> `} `; } // groupPhotoAlbums is exported for the Search page, which needs the album a // photo belongs to in order to merge duplicate sources per album rather than // per file (docs/MESHBAY_DESIGN.md §9.11). It calls this one, never a copy: // a second implementation of the album key would keep agreeing with this one // right up until one of them changed. export { PhotosApp, groupPhotoAlbums };