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
+6 -2
View File
@@ -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}`;
}
+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 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 ?? [],
@@ -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;
}