Files
Oxicloud/frontend/src/routes/photos/+page.svelte
T
Claude ccb85f53c0 perf(frontend): virtualize the photos timeline (square + justified)
The Photos "moments" grid rendered every tile into the DOM, so a 20k-photo
library mounted ~140k nodes / 20k <img> elements, held ~196MB JS heap, took
~3.5s to first paint and scrolled at ~6fps. It also ran the justified-layout
maths inside the template (recomputed on every reactive change) and generated
client-side video thumbnails for every off-screen video, not just visible ones.

Introduce `VirtualRows` — a variable-height, section-aware sibling of
`VirtualList` — and flatten the grouped timeline into one list of fixed-height
rows (a date header or a strip of explicitly-sized tiles) shared by both the
square and justified layouts. Only the rows near the viewport are mounted; a
prefix-sum offset table + binary search find the visible band, and a spacer
reserves the full height so the sticky header and load-more sentinel are
unchanged. The justified packing now runs once per groups/width/layout change
in a $derived, not per render.

To avoid duplicating the scroll-tracking logic across the two windowing
components, extract it into a `useVirtualWindow` composable (scroll-ancestor
detection + rAF-throttled aboveBy/viewportH signals); `VirtualList` is
refactored onto it with identical measured numbers.

Measured in headless Chromium (1280x900), synthetic photos, before/after:

  SQUARE      | mount→tiles | DOM nodes | <img> | JS heap | scroll frame
  ------------+-------------+-----------+-------+---------+-------------
   2000       | 416→94 ms   | 14k→629   |2000→96| 21→5 MB | 29→29 ms
   5000       | 916→114 ms  | 35k→629   |5000→96| 50→9 MB | 62→26 ms
  20000       | 3455→220 ms | 140k→629  |20k→96 |196→29 MB|152→33 ms

  JUSTIFIED 20000: mount 245 ms · DOM 315 · <img> 44 · heap 33 MB · ~60fps

Rendered DOM, mounted <img> count and heap are now flat (O(visible)) regardless
of library size; mount is ~16x faster and scroll jank drops from 413 to ≤24
frames. Off-screen video-thumbnail generation no longer fires for non-visible
tiles. Correctness verified by probing a deep scroll in both layouts (tiles
land within the viewport band; square cells equal-width, justified rows
aspect-preserving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:25:12 +00:00

726 lines
20 KiB
Svelte

<script lang="ts">
import Button from '$lib/components/Button.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import PeopleView from '$lib/components/PeopleView.svelte';
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import PlacesMap from '$lib/components/PlacesMap.svelte';
import VirtualRows from '$lib/components/VirtualRows.svelte';
import { useSelection } from '$lib/composables/useSelection.svelte';
import { errorToast } from '$lib/utils/errors';
import { onMount } from 'svelte';
import {
batchTrash,
fetchPhotos,
uploadThumbnail,
type PhotoItem
} from '$lib/api/endpoints/photos';
import { peopleEnabled } from '$lib/api/endpoints/people';
import { fileDownloadUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { isVideo, photoTimestamp } from '$lib/utils/media';
type Tab = 'moments' | 'places' | 'people';
let tab = $state<Tab>('moments');
let peopleAvailable = $state(false);
let items = $state<PhotoItem[]>([]);
let cursor = $state<string | null>(null);
let exhausted = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
let sentinel = $state<HTMLElement | null>(null);
/** Usable content width of the grid, for the justified layout. */
let gridWidth = $state(0);
type GroupMode = 'day' | 'month' | 'year';
type LayoutMode = 'square' | 'justified';
const GROUP_KEY = 'oxicloud-photos-group';
const LAYOUT_KEY = 'oxicloud-photos-layout';
let groupMode = $state<GroupMode>('month');
let layoutMode = $state<LayoutMode>('square');
const selected = useSelection();
let lightbox = $state(-1); // index into `items`, -1 = closed
/** Client-generated video frame thumbnails (file id → data/URL). */
let videoThumbs = $state<Record<string, string>>({});
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function bucketKey(d: Date): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
function bucketLabel(d: Date): string {
if (groupMode === 'year') return `${d.getFullYear()}`;
if (groupMode === 'month')
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
const groups = $derived.by(() => {
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
const index = new Map<string, number>();
for (const p of items) {
const d = new Date(photoTimestamp(p));
const key = bucketKey(d);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: bucketLabel(d), photos: [] });
}
out[i].photos.push(p);
}
return out;
});
interface JustifiedTile {
file: PhotoItem;
w: number;
h: number;
}
/**
* Pack files into justified rows (Flickr-style): each full row is scaled to
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
* fall back to 1:1.
*/
function justifiedRows(
files: PhotoItem[],
width: number
): Array<{ height: number; tiles: JustifiedTile[] }> {
const gap = 8;
const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200;
const rows: Array<{ height: number; tiles: JustifiedTile[] }> = [];
let cur: Array<{ file: PhotoItem; aspect: number }> = [];
let aspectSum = 0;
for (const file of files) {
let aspect = file.width && file.height ? file.width / file.height : 1;
if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1;
aspect = Math.min(Math.max(aspect, 0.4), 3);
cur.push({ file, aspect });
aspectSum += aspect;
const rowWidth = aspectSum * target + (cur.length - 1) * gap;
if (rowWidth >= width) {
const h = (width - (cur.length - 1) * gap) / aspectSum;
rows.push({
height: Math.round(h),
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * h)),
h: Math.round(h)
}))
});
cur = [];
aspectSum = 0;
}
}
if (cur.length) {
rows.push({
height: target,
tiles: cur.map((tt) => ({
file: tt.file,
w: Math.max(1, Math.round(tt.aspect * target)),
h: target
}))
});
}
return rows;
}
// ── Virtualized row model ────────────────────────────────────────────────
// Flatten the groups into a single list of fixed-height rows (a date header
// or a strip of sized tiles), so VirtualRows can window the whole timeline —
// only the rows near the viewport are mounted, regardless of library size.
const SQUARE_GAP = 4; // .25rem, matches the old grid gap
const SQUARE_MIN = 144; // 9rem minmax floor
const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
const HEADER_H = 44;
type PhotoRow =
| { kind: 'header'; key: string; height: number; label: string; count: number }
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
const photoRows = $derived.by<PhotoRow[]>(() => {
const W = gridWidth;
if (W <= 0) return [];
const rows: PhotoRow[] = [];
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
for (const g of groups) {
rows.push({
kind: 'header',
key: `h:${g.key}`,
height: HEADER_H,
label: g.label,
count: g.photos.length
});
if (layoutMode === 'justified') {
const jrows = justifiedRows(g.photos, W);
for (let ri = 0; ri < jrows.length; ri++) {
rows.push({
kind: 'tiles',
key: `${g.key}:j${ri}`,
height: jrows[ri].height + JUSTIFIED_GAP,
gap: JUSTIFIED_GAP,
tiles: jrows[ri].tiles
});
}
} else {
for (let i = 0; i < g.photos.length; i += cols) {
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
rows.push({
kind: 'tiles',
key: `${g.key}:s${i}`,
height: cell + SQUARE_GAP,
gap: SQUARE_GAP,
tiles
});
}
}
}
return rows;
});
async function loadMore() {
if (loading || exhausted) return;
loading = true;
error = null;
try {
const page = await fetchPhotos(60, cursor);
items = [...items, ...page.items];
cursor = page.nextCursor;
if (!page.nextCursor) exhausted = true;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
exhausted = true;
} finally {
loading = false;
}
}
function setGroupMode(m: GroupMode) {
if (groupMode === m) return;
groupMode = m;
if (typeof localStorage !== 'undefined') localStorage.setItem(GROUP_KEY, m);
}
function setLayoutMode(m: LayoutMode) {
if (layoutMode === m) return;
layoutMode = m;
if (typeof localStorage !== 'undefined') localStorage.setItem(LAYOUT_KEY, m);
}
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
function onTileClick(p: PhotoItem) {
if (selected.size > 0) selected.toggle(p.id);
else lightbox = items.findIndex((x) => x.id === p.id);
}
function onDeletePhoto(id: string) {
items = items.filter((p) => p.id !== id);
selected.delete(id);
}
function downloadSelected() {
for (const id of selected.ids) {
const a = document.createElement('a');
a.href = fileDownloadUrl(id);
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
}
async function trashSelected() {
const ids = selected.values();
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photos'),
message: t('photos.confirm_delete', { n: ids.length }, 'Move {{n}} photos to trash?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
const trashed = await batchTrash(ids);
if (trashed.size > 0) {
items = items.filter((p) => !trashed.has(p.id));
for (const id of trashed) selected.delete(id);
}
if (trashed.size < ids.length) {
ui.notify(
t(
'photos.trash_partial',
{ ok: trashed.size, total: ids.length },
'{{ok}} of {{total}} moved to trash.'
),
'warning'
);
} else {
ui.notify(t('photos.trashed', { n: trashed.size }, '{{n}} moved to trash.'), 'success');
}
} catch (e) {
errorToast(e);
}
}
// ── Client-side video thumbnail generation ──────────────────────────────
// When the server has no thumbnail for a video tile the <img> errors; we
// then extract a frame with the browser's native decoder and upload it.
async function generateVideoThumb(file: PhotoItem) {
if (videoThumbs[file.id]) return;
try {
const bitmap = await frameFromVideo(`/api/files/${file.id}?inline=true`);
const SIZES: Array<['icon' | 'preview' | 'large', number, number]> = [
['icon', 150, 150],
['preview', 400, 400],
['large', 800, 800]
];
let previewData = '';
for (const [size, w, h] of SIZES) {
const blob = await bitmapToBlob(bitmap, w, h);
if (size === 'preview') previewData = await blobToDataUrl(blob);
await uploadThumbnail(file.id, size, blob).catch(() => {});
}
if (previewData) videoThumbs = { ...videoThumbs, [file.id]: previewData };
} catch {
// Keep the generic play badge on failure.
}
}
function frameFromVideo(src: string): Promise<ImageBitmap> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.src = src;
video.muted = true;
video.preload = 'metadata';
video.onloadedmetadata = () => {
video.currentTime = (video.duration || 3) / 3;
};
video.onseeked = async () => {
try {
const bitmap = await createImageBitmap(video);
video.removeAttribute('src');
video.load();
resolve(bitmap);
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
};
video.onerror = () => reject(new Error('video frame extraction failed'));
});
}
async function bitmapToBlob(bitmap: ImageBitmap, tw: number, th: number): Promise<Blob> {
const ratio = bitmap.width / bitmap.height;
const target = tw / th;
const w = ratio > target ? tw : Math.round(th * ratio);
const h = ratio > target ? Math.round(tw / ratio) : th;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, w, h);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas toBlob failed'))),
'image/jpeg',
0.8
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('blob read failed'));
reader.readAsDataURL(blob);
});
}
onMount(() => {
const savedGroup = typeof localStorage !== 'undefined' ? localStorage.getItem(GROUP_KEY) : null;
if (savedGroup === 'day' || savedGroup === 'month' || savedGroup === 'year')
groupMode = savedGroup;
const savedLayout =
typeof localStorage !== 'undefined' ? localStorage.getItem(LAYOUT_KEY) : null;
if (savedLayout === 'square' || savedLayout === 'justified') layoutMode = savedLayout;
void loadMore();
void peopleEnabled().then((ok) => (peopleAvailable = ok));
if (!sentinel) return;
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: '600px' }
);
obs.observe(sentinel);
return () => obs.disconnect();
});
const MODES: GroupMode[] = ['day', 'month', 'year'];
</script>
<svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head>
<div class="page-sticky-header photos-head">
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
<div class="photos-subnav" role="tablist" aria-label={t('nav.photos', 'Photos')}>
<button
class="subnav__tab"
class:active={tab === 'moments'}
role="tab"
aria-selected={tab === 'moments'}
onclick={() => (tab = 'moments')}
>
{t('photos.tab_moments', 'Moments')}
</button>
<button
class="subnav__tab"
class:active={tab === 'places'}
role="tab"
aria-selected={tab === 'places'}
onclick={() => (tab = 'places')}
>
{t('photos.tab_places', 'Places')}
</button>
{#if peopleAvailable}
<button
class="subnav__tab"
class:active={tab === 'people'}
role="tab"
aria-selected={tab === 'people'}
onclick={() => (tab = 'people')}
>
{t('photos.tab_people', 'People')}
</button>
{/if}
</div>
</div>
{#if tab === 'moments'}
<div class="photos-toolbar">
<div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}>
{#each MODES as m (m)}
<button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}>
{t(`photos.${m}`, m)}
</button>
{/each}
</div>
<div class="seg" role="group" aria-label={t('photos.layout_square', 'Layout')}>
<button
class="seg__btn"
class:active={layoutMode === 'square'}
title={t('photos.layout_square', 'Grid')}
aria-label={t('photos.layout_square', 'Grid')}
onclick={() => setLayoutMode('square')}><Icon name="th" /></button
>
<button
class="seg__btn"
class:active={layoutMode === 'justified'}
title={t('photos.layout_justified', 'Justified')}
aria-label={t('photos.layout_justified', 'Justified')}
onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
>
</div>
</div>
{#if selected.size > 0}
<div class="batch-bar">
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
<div class="batch-bar__actions">
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button>
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button>
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
</div>
</div>
{/if}
{#if error}
<p class="status status--error" role="alert">{error}</p>
{:else if items.length === 0 && exhausted}
<EmptyState
icon="images"
title={t('photos.empty', 'No photos yet.')}
hint={t(
'photos.empty_hint',
'Photos and videos you upload will appear here, grouped by date.'
)}
/>
{:else}
<div class="photos-area">
<div class="photos-measure" bind:clientWidth={gridWidth}>
{#if photoRows.length}
<VirtualRows rows={photoRows} overscan={1000}>
{#snippet row(r)}
{#if r.kind === 'header'}
<div class="photos-group" style:height="{r.height}px">
{r.label} <span class="photos-group__count">{r.count}</span>
</div>
{:else}
<div class="photos-strip" style:height="{r.height}px" style:gap="{r.gap}px">
{#each r.tiles as cell (cell.file.id)}
{@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)}
{/each}
</div>
{/if}
{/snippet}
</VirtualRows>
{/if}
</div>
</div>
{/if}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
<PhotoLightbox {items} bind:index={lightbox} onDelete={onDeletePhoto} />
{:else if tab === 'places'}
<PlacesMap />
{:else if tab === 'people'}
<PeopleView />
{/if}
{#snippet tile(photo: PhotoItem, sizeStyle?: string)}
<div class="photo-tile" class:selected={selected.has(photo.id)} style={sizeStyle}>
<button class="photo-tile__open" onclick={() => onTileClick(photo)}>
{#if videoThumbs[photo.id]}
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
{:else}
<img
src={fileThumbnailUrl(photo.id, 'preview')}
srcset={`${fileThumbnailUrl(photo.id, 'icon')} 150w, ${fileThumbnailUrl(photo.id, 'preview')} 400w, ${fileThumbnailUrl(photo.id, 'large')} 800w`}
sizes="(max-width: 768px) 33vw, 200px"
alt={photo.name}
loading="lazy"
decoding="async"
onerror={isVideo(photo) ? () => generateVideoThumb(photo) : undefined}
/>
{/if}
{#if isVideo(photo)}
<span class="photo-tile__video-badge" aria-hidden="true"><Icon name="play" /></span>
{/if}
</button>
<button
class="photo-tile__check"
class:on={selected.has(photo.id)}
aria-label={t('common.select', 'Select')}
onclick={() => selected.toggle(photo.id)}
>
<Icon name="check" />
</button>
</div>
{/snippet}
<style>
.photos-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
padding: 1rem 1rem 0;
}
.page-title {
margin: 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.photos-subnav {
display: flex;
gap: var(--space-1);
}
.subnav__tab {
padding: var(--space-2) var(--space-3);
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-base);
}
.subnav__tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.photos-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) 1rem 0;
}
.seg {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.seg__btn {
display: grid;
place-items: center;
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
text-transform: capitalize;
}
.seg__btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin: var(--space-3) 1rem 0;
padding: var(--space-2) var(--space-3);
background: var(--color-accent-tint, var(--color-bg-hover));
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.batch-bar__actions {
display: flex;
gap: var(--space-2);
}
.photos-area {
padding: 0 1rem;
}
/* Date header — fixed height (set inline) so the virtualizer's offset table
matches the rendered layout exactly. */
.photos-group {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
font-size: 1rem;
color: var(--color-text-heading);
}
.photos-group__count {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: var(--weight-normal);
}
/* A horizontal strip of explicitly-sized tiles — one virtualized row, used by
both the square and justified layouts (the bottom gap is baked into the
row's declared height). */
.photos-strip {
display: flex;
}
.photo-tile {
position: relative;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.photo-tile.selected {
outline: 3px solid var(--color-accent);
outline-offset: -3px;
}
.photo-tile__open {
display: block;
width: 100%;
height: 100%;
border: none;
padding: 0;
cursor: pointer;
background: none;
}
.photo-tile__open img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-tile__video-badge {
position: absolute;
right: 6px;
bottom: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--color-scrim-control);
color: var(--color-on-accent);
display: grid;
place-items: center;
font-size: 0.7rem;
pointer-events: none;
}
.photo-tile__check {
position: absolute;
top: 6px;
left: 6px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--color-on-accent);
background: var(--color-scrim-control);
color: transparent;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.photo-tile:hover .photo-tile__check,
.photo-tile__check.on {
opacity: 1;
}
.photo-tile__check.on {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
}
.status {
text-align: center;
color: var(--color-text-muted);
padding: 2rem 0;
}
.status--error {
color: var(--color-danger-text);
}
.sentinel {
height: 1px;
}
</style>