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:
@@ -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>
|
||||
Reference in New Issue
Block a user