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`
`}
${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`
`}
<${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 };