feat(frontend): port Places, People & photo tabs to the Svelte app

Bring the Photos/People/Places UI that main added (in the legacy vanilla
frontend) into the SvelteKit rewrite, wired to the now-merged backend
(/api/photos/geo, /api/people/*).

Photos page (routes/photos/+page.svelte):
- Moments | Places | People sub-tabs (the People tab appears only when the
  faces feature is enabled, via a /api/people capability probe), mirroring
  the vanilla photos sub-nav.
- Square ↔ justified layout toggle. Justified uses a Flickr-style
  row-packer over the width/height the photos list endpoint returns
  (PhotoItem), falling back to 1:1 when dimensions are missing.

New components:
- PhotoLightbox.svelte — the lightbox extracted from the photos page into a
  reusable component (items + bindable index, onDelete callback) so the
  grid, People and Places all share one implementation (no duplication).
- PlacesMap.svelte — MapLibre GL map with server-clustered markers; the
  vector basemap is optional (probed at /basemaps/basemap.pmtiles, themed
  fallback otherwise). Cluster click zooms in or opens the lightbox.
- PeopleView.svelte — identity-cluster grid → per-person photo grid, with
  rename via the in-app prompt dialog.

Supporting:
- api/endpoints/people.ts (+ peopleEnabled probe); photos.ts gains
  fetchPhotosGeo + GeoCluster + PhotoItem; fileThumbnailUrl takes a size.
- lib/vendor/maplibre.ts — minimal typings + lazy loader for the vendored
  MapLibre GL + pmtiles globals (kept any-free for ESLint).
- utils/media.ts — shared isVideo / photoTimestamp / minimalPhotoItem.
- Vendored maplibre-gl 5.24.0 + pmtiles 4.4.1 under static/vendors and an
  optional static/basemaps dir, matching the PR's vendored-asset pattern.
- New photos.tab_*/layout_*/map_* + people.* keys in en.json.

Verified: npm run check (svelte-check + eslint + stylelint + prettier),
npm run test:unit (36 pass), and npm run build all green.
This commit is contained in:
Claude
2026-06-19 12:59:16 +00:00
parent 047e0f06ff
commit 5494efea35
16 changed files with 1663 additions and 456 deletions
+1
View File
@@ -13,3 +13,4 @@ src/lib/icons/registry.ts
static/locales/ static/locales/
static/vendors/ static/vendors/
static/workers/ static/workers/
static/basemaps/
+6 -2
View File
@@ -85,6 +85,10 @@ export function fileInlineUrl(fileId: string): string {
return `/api/files/${fileId}?inline=true`; return `/api/files/${fileId}?inline=true`;
} }
export function fileThumbnailUrl(fileId: string): string { /** Thumbnail URL for a file at the given size (server-rendered, content-typed). */
return `/api/files/${fileId}/thumbnail/preview`; export function fileThumbnailUrl(
fileId: string,
size: 'icon' | 'preview' | 'large' = 'preview'
): string {
return `/api/files/${fileId}/thumbnail/${size}`;
} }
+55
View File
@@ -0,0 +1,55 @@
/** People (faces) endpoints — ported from features/library/people.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
/** An identity cluster from `GET /api/people`. */
export interface Person {
id: string;
/** Absent until the user names the person. */
name?: string;
/** File id of the cover face's photo, for the tile thumbnail. */
cover_file_id?: string;
face_count: number;
is_hidden: boolean;
}
/**
* List identity clusters. The feature is gated on `OXICLOUD_ENABLE_FACES` —
* when it is off the route 404s; callers treat that as "faces disabled".
*/
export async function fetchPeople(): Promise<Person[]> {
const res = await apiFetch('/api/people', { credentials: 'same-origin' });
if (!res.ok) throw new Error(`people failed: ${res.status}`);
return (await res.json()) as Person[];
}
/**
* Probe whether the People feature is available (faces enabled). Used to reveal
* the People tab only when the backend can serve it.
*/
export async function peopleEnabled(): Promise<boolean> {
try {
const res = await apiFetch('/api/people', { credentials: 'same-origin' });
return res.ok;
} catch {
return false;
}
}
/** File ids of the photos a person appears in. */
export async function fetchPersonPhotos(personId: string): Promise<string[]> {
const res = await apiFetch(`/api/people/${personId}/photos`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`person photos failed: ${res.status}`);
return (await res.json()) as string[];
}
/** Rename a person, or pass `null` to clear the name. */
export async function renamePerson(personId: string, name: string | null): Promise<void> {
const res = await apiFetch(`/api/people/${personId}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename failed: ${res.status}`);
}
+34 -2
View File
@@ -3,8 +3,17 @@ import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf'; import { getCsrfHeaders } from '$lib/api/csrf';
import type { FileItem } from '$lib/api/types'; import type { FileItem } from '$lib/api/types';
/**
* A timeline photo/video. Extends {@link FileItem} with the pixel dimensions the
* list endpoint returns, used by the justified (aspect-preserving) grid layout.
*/
export interface PhotoItem extends FileItem {
width?: number;
height?: number;
}
export interface PhotoPage { export interface PhotoPage {
items: FileItem[]; items: PhotoItem[];
nextCursor: string | null; nextCursor: string | null;
} }
@@ -27,6 +36,29 @@ export interface BatchTrashResult {
failed: string[]; failed: string[];
} }
/** One server-side photo cluster for the Places map (`GET /api/photos/geo`). */
export interface GeoCluster {
lng: number;
lat: number;
count: number;
sample_file_id: string;
}
/**
* Fetch geotagged-photo clusters for a viewport. The backend aggregates
* server-side on a grid keyed by zoom, so the client draws one lightweight
* marker per cluster — no client-side clustering needed. `bbox` is
* `"west,south,east,north"` in decimal degrees. Available only when the
* Places feature is enabled (otherwise the route 404s).
*/
export async function fetchPhotosGeo(bbox: string, zoom: number): Promise<GeoCluster[]> {
const res = await apiFetch(`/api/photos/geo?bbox=${encodeURIComponent(bbox)}&zoom=${zoom}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`photos geo failed: ${res.status}`);
return (await res.json()) as GeoCluster[];
}
/** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */ /** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */
const BATCH_CHUNK_SIZE = 1000; const BATCH_CHUNK_SIZE = 1000;
@@ -40,7 +72,7 @@ export async function fetchPhotos(limit = 60, before?: string | null): Promise<P
if (before) url += `&before=${encodeURIComponent(before)}`; if (before) url += `&before=${encodeURIComponent(before)}`;
const res = await apiFetch(url, { credentials: 'same-origin' }); const res = await apiFetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`photos failed: ${res.status}`); if (!res.ok) throw new Error(`photos failed: ${res.status}`);
const items = (await res.json()) as FileItem[]; const items = (await res.json()) as PhotoItem[];
const cursor = res.headers.get('X-Next-Cursor'); const cursor = res.headers.get('X-Next-Cursor');
return { return {
items: items ?? [], items: items ?? [],
@@ -0,0 +1,297 @@
<script lang="ts">
/**
* People (faces): a grid of identity clusters from `GET /api/people`; clicking
* a person shows their photos in the shared lightbox. Faces are detected and
* clustered server-side, so this view is read-mostly (list, drill-in, rename).
* Gated on `OXICLOUD_ENABLE_FACES` — when off the API 404s and we show a hint.
*/
import EmptyState from '$lib/components/EmptyState.svelte';
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import Icon from '$lib/icons/Icon.svelte';
import {
fetchPeople,
fetchPersonPhotos,
renamePerson,
type Person
} from '$lib/api/endpoints/people';
import { fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import { promptDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { errorMessage } from '$lib/utils/errors';
import { minimalPhotoItem } from '$lib/utils/media';
import { onMount } from 'svelte';
type View = 'list' | 'person';
let view = $state<View>('list');
let people = $state<Person[]>([]);
let loading = $state(true);
/** Set when the feature is unavailable (faces disabled) or the list errors. */
let disabled = $state(false);
// Drill-in state.
let current = $state<{ id: string; name: string } | null>(null);
let photos = $state<FileItem[]>([]);
let lightbox = $state(-1);
function personName(p: Person): string {
return p.name || t('people.unnamed', 'Unnamed');
}
async function loadList() {
loading = true;
disabled = false;
try {
people = await fetchPeople();
} catch {
people = [];
disabled = true;
} finally {
loading = false;
}
}
async function openPerson(p: Person) {
current = { id: p.id, name: personName(p) };
view = 'person';
photos = [];
lightbox = -1;
try {
const ids = await fetchPersonPhotos(p.id);
photos = ids.map(minimalPhotoItem);
} catch {
photos = [];
}
}
function backToList() {
view = 'list';
current = null;
lightbox = -1;
}
async function rename() {
if (!current) return;
const placeholder = t('people.unnamed', 'Unnamed');
const value = current.name === placeholder ? '' : current.name;
const next = await promptDialog({
title: t('people.rename_title', 'Name this person'),
message: t('people.name_label', 'Name'),
defaultValue: value
});
if (next === null) return;
const trimmed = next.trim();
try {
await renamePerson(current.id, trimmed || null);
current = { id: current.id, name: trimmed || placeholder };
// Keep the list in sync so a return trip shows the new name.
people = people.map((p) => (p.id === current?.id ? { ...p, name: trimmed || undefined } : p));
} catch (e) {
// Surface the failure inline via the dialog's own error channel is not
// available here; fall back to logging — rename is non-destructive.
console.error('rename failed:', errorMessage(e));
}
}
function onDeletePhoto(id: string) {
photos = photos.filter((p) => p.id !== id);
}
onMount(loadList);
</script>
{#if loading}
<p class="people-status">{t('common.loading', 'Loading…')}</p>
{:else if disabled}
<EmptyState icon="user-group" title={t('people.disabled', 'Face recognition is disabled')} />
{:else if view === 'list'}
{#if people.length === 0}
<EmptyState icon="user-group" title={t('people.empty', 'No people yet')} />
{:else}
<ul class="people-grid">
{#each people as person (person.id)}
<li>
<button class="person-tile" type="button" onclick={() => openPerson(person)}>
<span class="person-avatar">
{#if person.cover_file_id}
<img src={fileThumbnailUrl(person.cover_file_id, 'icon')} alt="" loading="lazy" />
{:else}
<Icon name="user-group" />
{/if}
</span>
<span class="person-name">{personName(person)}</span>
<span class="person-count">{person.face_count}</span>
</button>
</li>
{/each}
</ul>
{/if}
{:else if current}
<div class="people-toolbar">
<button
class="people-back"
type="button"
aria-label={t('people.back', 'Back')}
onclick={backToList}
>
<Icon name="arrow-left" />
</button>
<h2 class="people-title">{current.name}</h2>
<button
class="people-rename"
type="button"
aria-label={t('people.rename_title', 'Name this person')}
onclick={rename}
>
<Icon name="pen" />
</button>
</div>
<ul class="photos">
{#each photos as photo, i (photo.id)}
<li class="photos__cell">
<button class="photos__open" onclick={() => (lightbox = i)}>
<img src={fileThumbnailUrl(photo.id, 'preview')} alt="" loading="lazy" decoding="async" />
</button>
</li>
{/each}
</ul>
<PhotoLightbox items={photos} bind:index={lightbox} onDelete={onDeletePhoto} />
{/if}
<style>
.people-status {
text-align: center;
color: var(--color-text-muted);
padding: 2rem 0;
}
.people-grid {
list-style: none;
margin: 0;
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4);
}
.person-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
}
.person-tile:hover {
background: var(--color-bg-hover);
}
.person-avatar {
display: grid;
place-items: center;
width: 5.5rem;
height: 5.5rem;
border-radius: 50%;
overflow: hidden;
background: var(--color-bg-muted);
color: var(--color-text-muted);
font-size: 1.5rem;
}
.person-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.person-name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
}
.person-count {
font-size: var(--text-xs, 0.75rem);
color: var(--color-text-muted);
}
.people-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 1rem;
}
.people-back,
.people-rename {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.people-back:hover,
.people-rename:hover {
background: var(--color-bg-hover);
}
.people-title {
flex: 1;
margin: 0;
font-size: 1.25rem;
color: var(--color-text-heading);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.photos {
list-style: none;
margin: 0;
padding: 0 1rem 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem;
}
.photos__cell {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.photos__open {
display: block;
width: 100%;
height: 100%;
border: none;
padding: 0;
cursor: pointer;
background: none;
}
.photos__open img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
@@ -0,0 +1,377 @@
<script lang="ts">
/**
* Full-screen photo/video lightbox, shared by the Photos timeline, People and
* Places views. Driven by an `items` list and a bindable `index` (-1 = closed);
* deletions are reported via `onDelete` so the parent can update its own list.
*/
import Icon from '$lib/icons/Icon.svelte';
import { addFavorite } from '$lib/api/endpoints/favorites';
import {
deleteFile,
fileDownloadUrl,
fileInlineUrl,
fileThumbnailUrl
} from '$lib/api/endpoints/files';
import { fetchFileMetadata, type FileMetadata } from '$lib/api/endpoints/photos';
import type { FileItem } from '$lib/api/types';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { errorToast } from '$lib/utils/errors';
import { isVideo, photoTimestamp } from '$lib/utils/media';
interface Props {
items: FileItem[];
/** Current index into `items`; -1 means closed. */
index: number;
/** Called after a successful delete so the parent can drop it from `items`. */
onDelete?: (id: string) => void;
}
let { items, index = $bindable(), onDelete }: Props = $props();
let showingOriginal = $state(false);
let fullResBusy = $state(false);
let meta = $state('');
let favorited = $state(false);
/** Token guarding against stale async loads during rapid prev/next. */
let generation = 0;
const item = $derived(index >= 0 ? (items[index] ?? null) : null);
// Clamp the index when the list shrinks under us (e.g. after a delete): drop
// to the last item, or close when nothing is left.
$effect(() => {
if (index < 0) return;
if (items.length === 0) index = -1;
else if (index >= items.length) index = items.length - 1;
});
function baseMeta(p: FileItem): string {
const dateStr = new Date(photoTimestamp(p)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
}
function applyMetadata(p: FileItem, md: FileMetadata) {
const parts = [baseMeta(p)];
if (md.camera_make || md.camera_model) {
parts.push([md.camera_make, md.camera_model].filter(Boolean).join(' '));
}
if (md.width && md.height) parts.push(`${md.width}×${md.height}`);
meta = parts.join(' · ');
}
/** Reset per-item state and kick off metadata + neighbour preload. */
function showItem(p: FileItem) {
const gen = ++generation;
showingOriginal = p.mime_type === 'image/gif';
fullResBusy = false;
favorited = false;
meta = baseMeta(p);
preloadNeighbors();
void fetchFileMetadata(p.id).then((md) => {
if (md && gen === generation) applyMetadata(p, md);
});
}
// Re-run per-item setup whenever the visible item changes.
$effect(() => {
if (item) showItem(item);
});
function preloadNeighbors() {
for (const i of [index - 1, index + 1]) {
const it = items[i];
if (it && !isVideo(it)) {
const pre = new Image();
pre.src = fileThumbnailUrl(it.id, 'large');
}
}
}
/** The image src to display: large thumbnail first, original on expand/GIF. */
const imgSrc = $derived(
item ? (showingOriginal ? fileInlineUrl(item.id) : fileThumbnailUrl(item.id, 'large')) : ''
);
function onImgError() {
if (!item) return;
// Thumbnail missing → fall back to the original; original failing is terminal.
if (!showingOriginal) showingOriginal = true;
}
function onImgLoad() {
fullResBusy = false;
}
function expandFullRes() {
if (!item || showingOriginal) return;
showingOriginal = true;
fullResBusy = true;
}
function download() {
if (!item) return;
const a = document.createElement('a');
a.href = fileDownloadUrl(item.id);
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
}
async function toggleFavorite() {
if (!item) return;
try {
await addFavorite('file', item.id);
favorited = !favorited;
} catch (e) {
errorToast(e);
}
}
async function remove() {
if (!item) return;
const target = item;
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photo'),
message: t('photos.confirm_delete_one', { name: target.name }, 'Delete {{name}}?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await deleteFile(target.id);
onDelete?.(target.id);
} catch (e) {
errorToast(e);
}
}
function prev() {
if (index > 0) index -= 1;
}
function next() {
if (index >= 0 && index < items.length - 1) index += 1;
}
function close() {
index = -1;
}
function onKeydown(e: KeyboardEvent) {
if (index < 0) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if item}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={item.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div class="lb__info">
<div class="lb__filename">{item.name}</div>
<div class="lb__meta">{meta}</div>
</div>
<button class="lb__close" aria-label={t('common.close', 'Close')} onclick={close}>×</button>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={index === 0}
onclick={(e) => {
e.stopPropagation();
prev();
}}><Icon name="chevron-left" /></button
>
<div class="lb__content">
{#if isVideo(item)}
{#key item.id}
<video class="lb__media" controls autoplay poster={fileThumbnailUrl(item.id, 'large')}>
<source src={fileInlineUrl(item.id)} type={item.mime_type} />
</video>
{/key}
{:else}
<img
class="lb__media"
src={imgSrc}
alt={item.name}
onload={onImgLoad}
onerror={onImgError}
/>
{/if}
</div>
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={index === items.length - 1}
onclick={(e) => {
e.stopPropagation();
next();
}}><Icon name="chevron-right" /></button
>
<div class="lb__toolbar">
{#if !isVideo(item) && item.mime_type !== 'image/gif' && !showingOriginal}
<button
class="lb__tool"
title={t('photos.full_resolution', 'Full resolution')}
disabled={fullResBusy}
onclick={expandFullRes}><Icon name={fullResBusy ? 'spinner' : 'expand'} /></button
>
{/if}
<button class="lb__tool" title={t('common.download', 'Download')} onclick={download}>
<Icon name="download" />
</button>
<button
class="lb__tool"
class:active={favorited}
title={t('common.favorite', 'Favorite')}
onclick={toggleFavorite}><Icon name={favorited ? 'star' : 'star-outline'} /></button
>
<button class="lb__tool" title={t('common.delete', 'Delete')} onclick={remove}>
<Icon name="trash" />
</button>
</div>
<div class="lb__counter">{index + 1} / {items.length}</div>
</div>
{/if}
<style>
.lb {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-lightbox-overlay);
display: flex;
align-items: center;
justify-content: center;
}
.lb__content {
max-width: 92vw;
max-height: 88vh;
display: flex;
align-items: center;
justify-content: center;
}
.lb__media {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
}
.lb__info {
position: absolute;
top: 1rem;
left: 1rem;
color: var(--color-on-accent);
max-width: 60vw;
}
.lb__filename {
font-weight: var(--weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb__meta {
font-size: var(--text-sm);
opacity: 0.8;
}
.lb__close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
line-height: 1;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
}
.lb__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
padding: 1rem;
}
.lb__nav:disabled {
opacity: 0.3;
cursor: default;
}
.lb__nav--prev {
left: 0.5rem;
}
.lb__nav--next {
right: 0.5rem;
}
.lb__toolbar {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-2);
}
.lb__tool {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--color-scrim-control);
color: var(--color-on-accent);
cursor: pointer;
display: grid;
place-items: center;
}
.lb__tool:disabled {
opacity: 0.5;
cursor: default;
}
.lb__tool.active {
color: var(--color-accent);
}
.lb__counter {
position: absolute;
bottom: 1rem;
right: 1rem;
color: var(--color-on-accent);
font-size: var(--text-sm);
opacity: 0.8;
}
</style>
@@ -0,0 +1,342 @@
<script lang="ts">
/**
* Places: geotagged photos on a self-hosted MapLibre GL map. Clusters are
* computed server-side (`GET /api/photos/geo`), so we draw one lightweight HTML
* marker per cluster — no glyphs/sprites, no client-side clustering. The vector
* basemap is optional: if `/basemaps/basemap.pmtiles` is present it is read over
* HTTP Range (pmtiles.js); otherwise the map falls back to a themed background
* and still shows the clusters.
*/
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { fetchPhotosGeo, type GeoCluster } from '$lib/api/endpoints/photos';
import { fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
import { minimalPhotoItem } from '$lib/utils/media';
import {
loadMapLibs,
type LngLatBounds,
type MapLibreMap,
type MapLibs,
type MapMarker
} from '$lib/vendor/maplibre';
import { onDestroy, onMount } from 'svelte';
const BASEMAP_URL = '/basemaps/basemap.pmtiles';
let mapEl = $state<HTMLDivElement | null>(null);
let loading = $state(true);
let error = $state(false);
let libs: MapLibs | null = null;
let map: MapLibreMap | null = null;
let markers: MapMarker[] = [];
let moveTimer = 0;
let hasBasemap: boolean | null = null;
// Lightbox drill-in (single representative photo).
let lbItems = $state<FileItem[]>([]);
let lbIndex = $state(-1);
function isDark(): boolean {
const attr = document.documentElement.getAttribute('data-color-scheme');
if (attr === 'dark') return true;
if (attr === 'light') return false;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
}
/** Whether a basemap .pmtiles is available (cached after first probe). */
async function checkBasemap(): Promise<boolean> {
if (hasBasemap !== null) return hasBasemap;
try {
const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } });
hasBasemap = res.ok; // 200/206 = present, 404 = absent
} catch {
hasBasemap = false;
}
return hasBasemap;
}
/** Minimal MapLibre style: themed background only (no basemap). */
function blankStyle(): Record<string, unknown> {
return {
version: 8,
sources: {},
layers: [
{
id: 'bg',
type: 'background',
paint: { 'background-color': isDark() ? '#0f172a' : '#e8eef3' }
}
]
};
}
/** Label-light Protomaps vector style (no glyphs/sprites required). */
function basemapStyle(): Record<string, unknown> {
const dark = isDark();
const c = dark
? {
earth: '#1b2433',
land: '#222d3d',
water: '#0d1b2a',
roads: '#3a4860',
buildings: '#2a3547',
boundary: '#475569'
}
: {
earth: '#f3efe9',
land: '#e9e4da',
water: '#a8c8e8',
roads: '#ffffff',
buildings: '#e0dccf',
boundary: '#c9c2b6'
};
return {
version: 8,
sources: {
protomaps: {
type: 'vector',
url: `pmtiles://${BASEMAP_URL}`,
attribution: 'Protomaps © OpenStreetMap'
}
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': c.earth } },
{
id: 'earth',
type: 'fill',
source: 'protomaps',
'source-layer': 'earth',
paint: { 'fill-color': c.earth }
},
{
id: 'landuse',
type: 'fill',
source: 'protomaps',
'source-layer': 'landuse',
paint: { 'fill-color': c.land, 'fill-opacity': 0.6 }
},
{
id: 'water',
type: 'fill',
source: 'protomaps',
'source-layer': 'water',
paint: { 'fill-color': c.water }
},
{
id: 'roads',
type: 'line',
source: 'protomaps',
'source-layer': 'roads',
minzoom: 7,
paint: { 'line-color': c.roads, 'line-width': 0.8 }
},
{
id: 'buildings',
type: 'fill',
source: 'protomaps',
'source-layer': 'buildings',
minzoom: 13,
paint: { 'fill-color': c.buildings }
},
{
id: 'boundaries',
type: 'line',
source: 'protomaps',
'source-layer': 'boundaries',
paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] }
}
]
};
}
async function initMap() {
if (!mapEl) return;
try {
libs = await loadMapLibs();
} catch {
error = true;
loading = false;
return;
}
const { maplibregl, pmtiles } = libs;
const basemap = await checkBasemap();
if (basemap) {
try {
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
} catch {
/* fall through to a basemap-less map */
}
}
map = new maplibregl.Map({
container: mapEl,
style: basemap ? basemapStyle() : blankStyle(),
center: [0, 25],
zoom: 1.3,
attributionControl: false
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
if (basemap) {
map.addControl(
new maplibregl.AttributionControl({
customAttribution:
'Protomaps © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
})
);
}
map.on('load', () => {
loading = false;
void refreshClusters(true);
});
map.on('moveend', () => {
clearTimeout(moveTimer);
moveTimer = window.setTimeout(() => void refreshClusters(false), 250);
});
}
/** Fetch clusters for the current viewport and render them.
* @param fit Fit the map to the returned clusters (first load only). */
async function refreshClusters(fit: boolean) {
if (!map) return;
const b = map.getBounds();
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
const zoom = Math.round(map.getZoom());
try {
const clusters = await fetchPhotosGeo(bbox, zoom);
renderMarkers(clusters);
if (fit && clusters.length) fitTo(clusters);
} catch {
/* transient geo fetch failure — leave the current markers in place */
}
}
function renderMarkers(clusters: GeoCluster[]) {
for (const m of markers) m.remove();
markers = [];
if (!libs || !map) return;
const { maplibregl } = libs;
for (const c of clusters) {
const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6));
const el = document.createElement('div');
el.className = 'places-cluster';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.backgroundImage = `url(${fileThumbnailUrl(c.sample_file_id, 'icon')})`;
if (c.count > 1) {
const count = document.createElement('span');
count.className = 'places-cluster__count';
count.textContent = String(c.count);
el.appendChild(count);
}
el.addEventListener('click', () => onClusterClick(c));
markers.push(new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(map));
}
}
function onClusterClick(c: GeoCluster) {
if (!map) return;
const zoom = map.getZoom();
if (c.count === 1 || zoom >= 16) {
lbItems = [minimalPhotoItem(c.sample_file_id)];
lbIndex = 0;
} else {
map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) });
}
}
function fitTo(clusters: GeoCluster[]) {
if (!libs || !map) return;
const bounds: LngLatBounds = new libs.maplibregl.LngLatBounds();
for (const c of clusters) bounds.extend([c.lng, c.lat]);
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 });
}
onMount(initMap);
onDestroy(() => {
clearTimeout(moveTimer);
for (const m of markers) m.remove();
markers = [];
map?.remove();
map = null;
});
</script>
<div class="places">
<div class="places__map" bind:this={mapEl}></div>
{#if loading && !error}
<div class="places__loading"><Icon name="spinner" /></div>
{/if}
{#if error}
<div class="places__error">{t('photos.map_error', 'Could not load the map')}</div>
{/if}
</div>
<PhotoLightbox items={lbItems} bind:index={lbIndex} />
<style>
.places {
position: relative;
height: calc(100vh - 8rem);
min-height: 24rem;
}
.places__map {
position: absolute;
inset: 0;
}
.places__loading,
.places__error {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
pointer-events: none;
}
.places__loading :global(svg) {
animation: places-spin 1s linear infinite;
font-size: 1.5rem;
}
@keyframes places-spin {
to {
transform: rotate(360deg);
}
}
/* Cluster markers are created imperatively by MapLibre, outside Svelte's
scoped styles — hence :global. */
:global(.places-cluster) {
position: relative;
border-radius: 50%;
background-size: cover;
background-position: center;
border: 2px solid var(--color-on-accent);
box-shadow: 0 1px 4px var(--color-overlay-shadow);
cursor: pointer;
}
:global(.places-cluster__count) {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 11px;
font-weight: var(--weight-bold);
display: grid;
place-items: center;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
/** Shared helpers for the photo/video timeline (used by the grid, lightbox,
* People and Places views). */
import type { FileItem } from '$lib/api/types';
/** True for video tiles (they get a play badge and client-side frame thumbs). */
export function isVideo(p: FileItem): boolean {
return (p.mime_type ?? '').startsWith('video/');
}
/**
* EXIF-aware capture timestamp in milliseconds. `sort_date`/`created_at` are
* stored in seconds; values below ~1e12 are treated as seconds and scaled up.
*/
export function photoTimestamp(p: FileItem): number {
const v = p.sort_date || p.created_at || 0;
return v < 1e12 ? v * 1000 : v;
}
/**
* Build a minimal {@link FileItem} from just an id — used by People and Places
* to open the lightbox by id and let it lazily fetch the rest (name, EXIF).
*/
export function minimalPhotoItem(id: string): FileItem {
return {
category: 'image',
created_at: 0,
icon_class: '',
icon_special_class: '',
id,
mime_type: 'image/jpeg',
modified_at: 0,
name: '',
owner_id: '',
folder_id: '',
path: '',
size: 0,
size_formatted: '',
sort_date: 0,
etag: '',
content_hash: ''
};
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Minimal typings + lazy loader for the vendored MapLibre GL + pmtiles globals.
*
* The libraries are heavy (~1 MB) and only the Places map needs them, so they
* are vendored under `/vendors` (not bundled) and injected on first use — the
* same pattern the legacy frontend used. We declare only the small slice of the
* MapLibre API the Places view touches, so the rest of the app stays `any`-free.
*/
export interface MapBounds {
getWest(): number;
getSouth(): number;
getEast(): number;
getNorth(): number;
}
export interface LngLatBounds {
extend(lngLat: [number, number]): LngLatBounds;
isEmpty(): boolean;
}
export interface MapMarker {
setLngLat(lngLat: [number, number]): MapMarker;
addTo(map: MapLibreMap): MapMarker;
remove(): void;
}
export interface MapLibreMap {
addControl(control: unknown, position?: string): MapLibreMap;
on(type: string, listener: () => void): void;
getBounds(): MapBounds;
getZoom(): number;
resize(): void;
easeTo(opts: { center: [number, number]; zoom: number }): void;
fitBounds(
bounds: LngLatBounds,
opts?: { padding?: number; maxZoom?: number; duration?: number }
): void;
remove(): void;
}
export interface MapLibreModule {
Map: new (opts: Record<string, unknown>) => MapLibreMap;
Marker: new (opts: { element: HTMLElement }) => MapMarker;
NavigationControl: new (opts?: { showCompass?: boolean }) => unknown;
AttributionControl: new (opts?: { customAttribution?: string }) => unknown;
LngLatBounds: new () => LngLatBounds;
addProtocol(name: string, fn: unknown): void;
}
export interface PMTilesModule {
Protocol: new () => { tile: unknown };
}
export interface MapLibs {
maplibregl: MapLibreModule;
pmtiles: PMTilesModule;
}
let cached: MapLibs | null = null;
/** Inject a vendored script once, resolving when it has loaded. */
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[data-vendor="${src}"]`)) {
resolve();
return;
}
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.vendor = src;
s.addEventListener('load', () => resolve());
s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
document.head.appendChild(s);
});
}
/** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */
export async function loadMapLibs(): Promise<MapLibs> {
if (cached) return cached;
if (!document.querySelector('link[data-vendor="maplibre-css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = '/vendors/maplibre-gl.css';
l.dataset.vendor = 'maplibre-css';
document.head.appendChild(l);
}
await loadScript('/vendors/maplibre-gl.js');
await loadScript('/vendors/pmtiles.js');
const w = window as unknown as { maplibregl?: MapLibreModule; pmtiles?: PMTilesModule };
if (!w.maplibregl || !w.pmtiles) throw new Error('map libraries failed to initialise');
cached = { maplibregl: w.maplibregl, pmtiles: w.pmtiles };
return cached;
}
+298 -451
View File
@@ -1,50 +1,52 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import EmptyState from '$lib/components/EmptyState.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 { useSelection } from '$lib/composables/useSelection.svelte'; import { useSelection } from '$lib/composables/useSelection.svelte';
import { errorMessage, errorToast } from '$lib/utils/errors'; import { errorToast } from '$lib/utils/errors';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
batchTrash, batchTrash,
fetchFileMetadata,
fetchPhotos, fetchPhotos,
uploadThumbnail, uploadThumbnail,
type FileMetadata type PhotoItem
} from '$lib/api/endpoints/photos'; } from '$lib/api/endpoints/photos';
import { addFavorite } from '$lib/api/endpoints/favorites'; import { peopleEnabled } from '$lib/api/endpoints/people';
import { deleteFile, fileDownloadUrl, fileInlineUrl } from '$lib/api/endpoints/files'; import { fileDownloadUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte'; import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
import { isVideo, photoTimestamp } from '$lib/utils/media';
let items = $state<FileItem[]>([]); 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 cursor = $state<string | null>(null);
let exhausted = $state(false); let exhausted = $state(false);
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let sentinel = $state<HTMLElement | 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 GroupMode = 'day' | 'month' | 'year';
type LayoutMode = 'square' | 'justified';
const GROUP_KEY = 'oxicloud-photos-group'; const GROUP_KEY = 'oxicloud-photos-group';
const LAYOUT_KEY = 'oxicloud-photos-layout';
let groupMode = $state<GroupMode>('month'); let groupMode = $state<GroupMode>('month');
let layoutMode = $state<LayoutMode>('square');
const selected = useSelection(); const selected = useSelection();
let lightbox = $state(-1); // index into `items`, -1 = closed let lightbox = $state(-1); // index into `items`, -1 = closed
/** Client-generated video frame thumbnails (file id → data/URL). */ /** Client-generated video frame thumbnails (file id → data/URL). */
let videoThumbs = $state<Record<string, string>>({}); let videoThumbs = $state<Record<string, string>>({});
function isVideo(p: FileItem): boolean {
return (p.mime_type ?? '').startsWith('video/');
}
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */ /** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function ts(p: FileItem): number {
const v = p.sort_date || p.created_at || 0;
return v < 1e12 ? v * 1000 : v;
}
function bucketKey(d: Date): string { function bucketKey(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
if (groupMode === 'year') return `${y}`; if (groupMode === 'year') return `${y}`;
@@ -66,10 +68,10 @@
} }
const groups = $derived.by(() => { const groups = $derived.by(() => {
const out: Array<{ key: string; label: string; photos: FileItem[] }> = []; const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
const index = new Map<string, number>(); const index = new Map<string, number>();
for (const p of items) { for (const p of items) {
const d = new Date(ts(p)); const d = new Date(photoTimestamp(p));
const key = bucketKey(d); const key = bucketKey(d);
let i = index.get(key); let i = index.get(key);
if (i === undefined) { if (i === undefined) {
@@ -82,14 +84,58 @@
return out; return out;
}); });
function iconUrl(id: string): string { interface JustifiedTile {
return `/api/files/${id}/thumbnail/icon`; file: PhotoItem;
w: number;
h: number;
} }
function previewUrl(id: string): string {
return `/api/files/${id}/thumbnail/preview`; /**
} * Pack files into justified rows (Flickr-style): each full row is scaled to
function largeUrl(id: string): string { * fill `width` while preserving every tile's aspect ratio. Missing dimensions
return `/api/files/${id}/thumbnail/large`; * 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;
} }
async function loadMore() { async function loadMore() {
@@ -102,7 +148,7 @@
cursor = page.nextCursor; cursor = page.nextCursor;
if (!page.nextCursor) exhausted = true; if (!page.nextCursor) exhausted = true;
} catch (e) { } catch (e) {
error = errorMessage(e); error = e instanceof Error ? e.message : String(e);
exhausted = true; exhausted = true;
} finally { } finally {
loading = false; loading = false;
@@ -115,10 +161,21 @@
if (typeof localStorage !== 'undefined') localStorage.setItem(GROUP_KEY, 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. */ /** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
function onTileClick(p: FileItem) { function onTileClick(p: PhotoItem) {
if (selected.size > 0) selected.toggle(p.id); if (selected.size > 0) selected.toggle(p.id);
else openLightbox(p); 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() { function downloadSelected() {
@@ -168,10 +225,10 @@
// When the server has no thumbnail for a video tile the <img> errors; we // 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. // then extract a frame with the browser's native decoder and upload it.
async function generateVideoThumb(file: FileItem) { async function generateVideoThumb(file: PhotoItem) {
if (videoThumbs[file.id]) return; if (videoThumbs[file.id]) return;
try { try {
const bitmap = await frameFromVideo(fileInlineUrl(file.id)); const bitmap = await frameFromVideo(`/api/files/${file.id}?inline=true`);
const SIZES: Array<['icon' | 'preview' | 'large', number, number]> = [ const SIZES: Array<['icon' | 'preview' | 'large', number, number]> = [
['icon', 150, 150], ['icon', 150, 150],
['preview', 400, 400], ['preview', 400, 400],
@@ -239,152 +296,15 @@
}); });
} }
// ── Lightbox ─────────────────────────────────────────────────────────────
let lbShowingOriginal = $state(false);
let lbFullResBusy = $state(false);
let lbMeta = $state('');
let lbFavorited = $state(false);
/** Token guarding against stale async loads during rapid prev/next. */
let lbGeneration = 0;
const lbItem = $derived(lightbox >= 0 ? (items[lightbox] ?? null) : null);
function baseMeta(p: FileItem): string {
const dateStr = new Date(ts(p)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
}
function applyMetadata(p: FileItem, md: FileMetadata) {
const parts = [baseMeta(p)];
if (md.camera_make || md.camera_model) {
parts.push([md.camera_make, md.camera_model].filter(Boolean).join(' '));
}
if (md.width && md.height) parts.push(`${md.width}×${md.height}`);
lbMeta = parts.join(' · ');
}
function openLightbox(p: FileItem) {
lightbox = items.findIndex((x) => x.id === p.id);
}
/** Reset per-item lightbox state and kick off metadata + neighbour preload. */
function showLightboxItem(p: FileItem) {
const generation = ++lbGeneration;
lbShowingOriginal = p.mime_type === 'image/gif';
lbFullResBusy = false;
lbFavorited = false;
lbMeta = baseMeta(p);
preloadNeighbors();
void fetchFileMetadata(p.id).then((md) => {
if (md && generation === lbGeneration) applyMetadata(p, md);
});
}
// Re-run per-item setup whenever the visible lightbox item changes.
$effect(() => {
if (lbItem) showLightboxItem(lbItem);
});
function preloadNeighbors() {
for (const i of [lightbox - 1, lightbox + 1]) {
const it = items[i];
if (it && !isVideo(it)) {
const pre = new Image();
pre.src = largeUrl(it.id);
}
}
}
/** The image src to display: large thumbnail first, original on expand/GIF. */
const lbImgSrc = $derived(
lbItem ? (lbShowingOriginal ? fileInlineUrl(lbItem.id) : largeUrl(lbItem.id)) : ''
);
function onLbImgError() {
if (!lbItem) return;
// Thumbnail missing → fall back to the original; original failing is terminal.
if (!lbShowingOriginal) {
lbShowingOriginal = true;
}
}
function onLbImgLoad() {
lbFullResBusy = false;
}
function expandFullRes() {
if (!lbItem || lbShowingOriginal) return;
lbShowingOriginal = true;
lbFullResBusy = true;
}
function lbDownload() {
if (!lbItem) return;
const a = document.createElement('a');
a.href = fileDownloadUrl(lbItem.id);
a.download = lbItem.name;
document.body.appendChild(a);
a.click();
a.remove();
}
async function lbToggleFavorite() {
if (!lbItem) return;
try {
await addFavorite('file', lbItem.id);
lbFavorited = !lbFavorited;
} catch (e) {
errorToast(e);
}
}
async function lbDelete() {
if (!lbItem) return;
const target = lbItem;
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photo'),
message: t('photos.confirm_delete_one', { name: target.name }, 'Delete {{name}}?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await deleteFile(target.id);
const at = items.findIndex((x) => x.id === target.id);
items = items.filter((x) => x.id !== target.id);
if (items.length === 0) {
lightbox = -1;
} else {
lightbox = Math.min(at, items.length - 1);
}
} catch (e) {
errorToast(e);
}
}
function lbPrev() {
if (lightbox > 0) lightbox -= 1;
}
function lbNext() {
if (lightbox >= 0 && lightbox < items.length - 1) lightbox += 1;
}
function onKeydown(e: KeyboardEvent) {
if (lightbox < 0) return;
if (e.key === 'Escape') lightbox = -1;
else if (e.key === 'ArrowLeft') lbPrev();
else if (e.key === 'ArrowRight') lbNext();
}
onMount(() => { onMount(() => {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(GROUP_KEY) : null; const savedGroup = typeof localStorage !== 'undefined' ? localStorage.getItem(GROUP_KEY) : null;
if (saved === 'day' || saved === 'month' || saved === 'year') groupMode = saved; 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 loadMore();
void peopleEnabled().then((ok) => (peopleAvailable = ok));
if (!sentinel) return; if (!sentinel) return;
const obs = new IntersectionObserver( const obs = new IntersectionObserver(
(entries) => { (entries) => {
@@ -400,173 +320,171 @@
</script> </script>
<svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head> <svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head>
<svelte:window onkeydown={onKeydown} />
<div class="page-sticky-header photos-head"> <div class="page-sticky-header photos-head">
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1> <h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
<div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}> <div class="photos-subnav" role="tablist" aria-label={t('nav.photos', 'Photos')}>
{#each MODES as m (m)} <button
<button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}> class="subnav__tab"
{t(`photos.${m}`, m)} 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> </button>
{/each} {/if}
</div> </div>
</div> </div>
{#if selected.size > 0} {#if tab === 'moments'}
<div class="batch-bar"> <div class="photos-toolbar">
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span> <div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}>
<div class="batch-bar__actions"> {#each MODES as m (m)}
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button> <button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}>
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button> {t(`photos.${m}`, m)}
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button> </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}
{#each groups as group (group.key)}
<h2 class="photos-group">
{group.label} <span class="photos-group__count">{group.photos.length}</span>
</h2>
<ul class="photos">
{#each group.photos as photo (photo.id)}
<li class="photos__cell" class:selected={selected.has(photo.id)}>
<button class="photos__open" onclick={() => onTileClick(photo)}>
{#if videoThumbs[photo.id]}
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
{:else}
<img
src={previewUrl(photo.id)}
srcset={`${iconUrl(photo.id)} 150w, ${previewUrl(photo.id)} 400w, ${largeUrl(photo.id)} 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="photos__video-badge" aria-hidden="true"><Icon name="play" /></span>
{/if}
</button>
<button
class="photos__check"
class:on={selected.has(photo.id)}
aria-label={t('common.select', 'Select')}
onclick={() => selected.toggle(photo.id)}
>
<Icon name="check" />
</button>
</li>
{/each} {/each}
</ul>
{/each}
{/if}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
{#if lbItem}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={lbItem.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
>
<div class="lb__info">
<div class="lb__filename">{lbItem.name}</div>
<div class="lb__meta">{lbMeta}</div>
</div> </div>
<div class="seg" role="group" aria-label={t('photos.layout_square', 'Layout')}>
<button <button
class="lb__close" class="seg__btn"
aria-label={t('common.close', 'Close')} class:active={layoutMode === 'square'}
onclick={() => (lightbox = -1)}>×</button title={t('photos.layout_square', 'Grid')}
> aria-label={t('photos.layout_square', 'Grid')}
onclick={() => setLayoutMode('square')}><Icon name="th" /></button
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0}
onclick={(e) => {
e.stopPropagation();
lbPrev();
}}><Icon name="chevron-left" /></button
>
<div class="lb__content">
{#if isVideo(lbItem)}
{#key lbItem.id}
<video class="lb__media" controls autoplay poster={largeUrl(lbItem.id)}>
<source src={fileInlineUrl(lbItem.id)} type={lbItem.mime_type} />
</video>
{/key}
{:else}
<img
class="lb__media"
src={lbImgSrc}
alt={lbItem.name}
onload={onLbImgLoad}
onerror={onLbImgError}
/>
{/if}
</div>
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={lightbox === items.length - 1}
onclick={(e) => {
e.stopPropagation();
lbNext();
}}><Icon name="chevron-right" /></button
>
<div class="lb__toolbar">
{#if !isVideo(lbItem) && lbItem.mime_type !== 'image/gif' && !lbShowingOriginal}
<button
class="lb__tool"
title={t('photos.full_resolution', 'Full resolution')}
disabled={lbFullResBusy}
onclick={expandFullRes}><Icon name={lbFullResBusy ? 'spinner' : 'expand'} /></button
>
{/if}
<button class="lb__tool" title={t('common.download', 'Download')} onclick={lbDownload}
><Icon name="download" /></button
> >
<button <button
class="lb__tool" class="seg__btn"
class:active={lbFavorited} class:active={layoutMode === 'justified'}
title={t('common.favorite', 'Favorite')} title={t('photos.layout_justified', 'Justified')}
onclick={lbToggleFavorite}><Icon name={lbFavorited ? 'star' : 'star-outline'} /></button aria-label={t('photos.layout_justified', 'Justified')}
> onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
<button class="lb__tool" title={t('common.delete', 'Delete')} onclick={lbDelete}
><Icon name="trash" /></button
> >
</div> </div>
<div class="lb__counter">{lightbox + 1} / {items.length}</div>
</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}>
{#each groups as group (group.key)}
<h2 class="photos-group">
{group.label} <span class="photos-group__count">{group.photos.length}</span>
</h2>
{#if layoutMode === 'justified' && gridWidth > 0}
{#each justifiedRows(group.photos, gridWidth) as row, ri (group.key + '-' + ri)}
<div class="photos-jrow" style:height="{row.height}px">
{#each row.tiles as cell (cell.file.id)}
{@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)}
{/each}
</div>
{/each}
{:else}
<ul class="photos">
{#each group.photos as photo (photo.id)}
<li
class="photos__cell photos__cell--square"
class:selected={selected.has(photo.id)}
>
{@render tile(photo)}
</li>
{/each}
</ul>
{/if}
{/each}
</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} {/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> <style>
.photos-head { .photos-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap;
padding: 1rem 1rem 0; padding: 1rem 1rem 0;
} }
@@ -576,6 +494,34 @@
color: var(--color-text-heading); 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 { .seg {
display: flex; display: flex;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -584,6 +530,8 @@
} }
.seg__btn { .seg__btn {
display: grid;
place-items: center;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border: none; border: none;
background: var(--color-bg-surface); background: var(--color-bg-surface);
@@ -614,9 +562,12 @@
gap: var(--space-2); gap: var(--space-2);
} }
.photos-area {
padding: 0 1rem;
}
.photos-group { .photos-group {
margin: var(--space-4) 0 var(--space-2); margin: var(--space-4) 0 var(--space-2);
padding: 0 1rem;
font-size: 1rem; font-size: 1rem;
color: var(--color-text-heading); color: var(--color-text-heading);
} }
@@ -630,26 +581,42 @@
.photos { .photos {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0 1rem; padding: 0;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem; gap: 0.25rem;
} }
.photos__cell { /* Justified rows: a flex row of aspect-preserving tiles. */
.photos-jrow {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.photo-tile {
position: relative; position: relative;
aspect-ratio: 1;
overflow: hidden; overflow: hidden;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--color-bg-muted); background: var(--color-bg-muted);
} }
.photos__cell.selected { .photos__cell--square,
.photos__cell--square .photo-tile {
aspect-ratio: 1;
height: 100%;
}
.photos__cell--square {
list-style: none;
}
.photo-tile.selected {
outline: 3px solid var(--color-accent); outline: 3px solid var(--color-accent);
outline-offset: -3px; outline-offset: -3px;
} }
.photos__open { .photo-tile__open {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -659,14 +626,14 @@
background: none; background: none;
} }
.photos__open img { .photo-tile__open img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.photos__video-badge { .photo-tile__video-badge {
position: absolute; position: absolute;
right: 6px; right: 6px;
bottom: 6px; bottom: 6px;
@@ -681,7 +648,7 @@
pointer-events: none; pointer-events: none;
} }
.photos__check { .photo-tile__check {
position: absolute; position: absolute;
top: 6px; top: 6px;
left: 6px; left: 6px;
@@ -698,12 +665,12 @@
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.photos__cell:hover .photos__check, .photo-tile:hover .photo-tile__check,
.photos__check.on { .photo-tile__check.on {
opacity: 1; opacity: 1;
} }
.photos__check.on { .photo-tile__check.on {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-on-accent); color: var(--color-on-accent);
border-color: var(--color-accent); border-color: var(--color-accent);
@@ -722,124 +689,4 @@
.sentinel { .sentinel {
height: 1px; height: 1px;
} }
.lb {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-lightbox-overlay);
display: flex;
align-items: center;
justify-content: center;
}
.lb__content {
max-width: 92vw;
max-height: 88vh;
display: flex;
align-items: center;
justify-content: center;
}
.lb__media {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
}
.lb__info {
position: absolute;
top: 1rem;
left: 1rem;
color: var(--color-on-accent);
max-width: 60vw;
}
.lb__filename {
font-weight: var(--weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb__meta {
font-size: var(--text-sm);
opacity: 0.8;
}
.lb__close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
line-height: 1;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
}
.lb__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
padding: 1rem;
}
.lb__nav:disabled {
opacity: 0.3;
cursor: default;
}
.lb__nav--prev {
left: 0.5rem;
}
.lb__nav--next {
right: 0.5rem;
}
.lb__toolbar {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-2);
}
.lb__tool {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--color-scrim-control);
color: var(--color-on-accent);
cursor: pointer;
display: grid;
place-items: center;
}
.lb__tool:disabled {
opacity: 0.5;
cursor: default;
}
.lb__tool.active {
color: var(--color-accent);
}
.lb__counter {
position: absolute;
bottom: 1rem;
right: 1rem;
color: var(--color-on-accent);
font-size: var(--text-sm);
opacity: 0.8;
}
</style> </style>
+5
View File
@@ -0,0 +1,5 @@
# The vector basemap is large (tens of MB) and operator-provided — never
# commit it to the repo. Drop a Protomaps `.pmtiles` here as `basemap.pmtiles`
# and the existing static file server (tower-http ServeDir, Range-capable)
# will serve it to the Places map. See README.md.
*.pmtiles
+33
View File
@@ -0,0 +1,33 @@
# Places basemap (optional)
The **Places** photo map renders your geotagged photos as clusters. It works
out of the box **without** a basemap (clusters on a plain background). To get a
real street/terrain backdrop, drop a self-hosted vector basemap here — no
third-party tile API, fully offline.
## How it works (Approach "A")
OxiCloud already serves `static/` through `tower-http`'s `ServeDir`, which
honours **HTTP Range** requests. A [PMTiles](https://docs.protomaps.com/pmtiles/)
basemap is a *single file* read directly by the browser via Range — so the
basemap is just a static file the app already knows how to serve. No extra
backend, no tile server, no API keys.
## Enabling it
1. Get a Protomaps `.pmtiles` basemap (vector, ODbL OpenStreetMap data):
- Whole planet z0–15 (~120 GB) or a smaller global `z0-6` (~60 MB), or
- A **regional extract** (recommended — only the area you need, a few MB):
```sh
# one-time, downloads only your bounding box from the remote planet
pmtiles extract https://build.protomaps.com/<DATE>.pmtiles basemap.pmtiles \
--bbox=<west>,<south>,<east>,<north>
```
See https://docs.protomaps.com/basemaps/downloads
2. Place it here as **`static/basemaps/basemap.pmtiles`** (this path is
git-ignored on purpose — see `.gitignore`).
3. Reload the Places view. The map will pick it up automatically.
The bundled style is **label-light** (water / land / roads / buildings, no
text) so it needs no glyph/sprite assets. Attribution “© OpenStreetMap”
(ODbL) is shown automatically when a basemap is present.
+16 -1
View File
@@ -88,7 +88,22 @@
"full_resolution": "Full resolution", "full_resolution": "Full resolution",
"group_by": "Group by", "group_by": "Group by",
"trash_partial": "{{ok}} of {{total}} moved to trash.", "trash_partial": "{{ok}} of {{total}} moved to trash.",
"trashed": "{{n}} moved to trash." "trashed": "{{n}} moved to trash.",
"layout_square": "Grid",
"layout_justified": "Justified",
"tab_moments": "Moments",
"tab_places": "Places",
"tab_people": "People",
"map_loading": "Loading map…",
"map_error": "Could not load the map"
},
"people": {
"unnamed": "Unnamed",
"empty": "No people yet",
"disabled": "Face recognition is disabled",
"rename_title": "Name this person",
"name_label": "Name",
"back": "Back"
}, },
"music": { "music": {
"create_playlist": "Create Playlist", "create_playlist": "Create Playlist",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long