5494efea35
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.
298 lines
6.9 KiB
Svelte
298 lines
6.9 KiB
Svelte
<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>
|