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:
@@ -85,6 +85,10 @@ export function fileInlineUrl(fileId: string): string {
|
||||
return `/api/files/${fileId}?inline=true`;
|
||||
}
|
||||
|
||||
export function fileThumbnailUrl(fileId: string): string {
|
||||
return `/api/files/${fileId}/thumbnail/preview`;
|
||||
/** Thumbnail URL for a file at the given size (server-rendered, content-typed). */
|
||||
export function fileThumbnailUrl(
|
||||
fileId: string,
|
||||
size: 'icon' | 'preview' | 'large' = 'preview'
|
||||
): string {
|
||||
return `/api/files/${fileId}/thumbnail/${size}`;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -3,8 +3,17 @@ import { apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
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 {
|
||||
items: FileItem[];
|
||||
items: PhotoItem[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
@@ -27,6 +36,29 @@ export interface BatchTrashResult {
|
||||
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. */
|
||||
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)}`;
|
||||
const res = await apiFetch(url, { credentials: 'same-origin' });
|
||||
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');
|
||||
return {
|
||||
items: items ?? [],
|
||||
|
||||
Reference in New Issue
Block a user