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:
@@ -13,3 +13,4 @@ src/lib/icons/registry.ts
|
|||||||
static/locales/
|
static/locales/
|
||||||
static/vendors/
|
static/vendors/
|
||||||
static/workers/
|
static/workers/
|
||||||
|
static/basemaps/
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
@@ -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: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+95
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* fill `width` while preserving every tile's aspect ratio. Missing dimensions
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
function largeUrl(id: string): string {
|
}
|
||||||
return `/api/files/${id}/thumbnail/large`;
|
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,10 +320,44 @@
|
|||||||
</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="photos-subnav" role="tablist" aria-label={t('nav.photos', 'Photos')}>
|
||||||
|
<button
|
||||||
|
class="subnav__tab"
|
||||||
|
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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'moments'}
|
||||||
|
<div class="photos-toolbar">
|
||||||
<div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}>
|
<div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}>
|
||||||
{#each MODES as m (m)}
|
{#each MODES as m (m)}
|
||||||
<button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}>
|
<button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}>
|
||||||
@@ -411,9 +365,25 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="seg" role="group" aria-label={t('photos.layout_square', 'Layout')}>
|
||||||
|
<button
|
||||||
|
class="seg__btn"
|
||||||
|
class:active={layoutMode === 'square'}
|
||||||
|
title={t('photos.layout_square', 'Grid')}
|
||||||
|
aria-label={t('photos.layout_square', 'Grid')}
|
||||||
|
onclick={() => setLayoutMode('square')}><Icon name="th" /></button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="seg__btn"
|
||||||
|
class:active={layoutMode === 'justified'}
|
||||||
|
title={t('photos.layout_justified', 'Justified')}
|
||||||
|
aria-label={t('photos.layout_justified', 'Justified')}
|
||||||
|
onclick={() => setLayoutMode('justified')}><Icon name="layer-group" /></button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if selected.size > 0}
|
{#if selected.size > 0}
|
||||||
<div class="batch-bar">
|
<div class="batch-bar">
|
||||||
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
|
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
|
||||||
<div class="batch-bar__actions">
|
<div class="batch-bar__actions">
|
||||||
@@ -422,31 +392,70 @@
|
|||||||
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
|
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="status status--error" role="alert">{error}</p>
|
<p class="status status--error" role="alert">{error}</p>
|
||||||
{:else if items.length === 0 && exhausted}
|
{:else if items.length === 0 && exhausted}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="images"
|
icon="images"
|
||||||
title={t('photos.empty', 'No photos yet.')}
|
title={t('photos.empty', 'No photos yet.')}
|
||||||
hint={t('photos.empty_hint', 'Photos and videos you upload will appear here, grouped by date.')}
|
hint={t(
|
||||||
|
'photos.empty_hint',
|
||||||
|
'Photos and videos you upload will appear here, grouped by date.'
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="photos-area">
|
||||||
|
<div class="photos-measure" bind:clientWidth={gridWidth}>
|
||||||
{#each groups as group (group.key)}
|
{#each groups as group (group.key)}
|
||||||
<h2 class="photos-group">
|
<h2 class="photos-group">
|
||||||
{group.label} <span class="photos-group__count">{group.photos.length}</span>
|
{group.label} <span class="photos-group__count">{group.photos.length}</span>
|
||||||
</h2>
|
</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">
|
<ul class="photos">
|
||||||
{#each group.photos as photo (photo.id)}
|
{#each group.photos as photo (photo.id)}
|
||||||
<li class="photos__cell" class:selected={selected.has(photo.id)}>
|
<li
|
||||||
<button class="photos__open" onclick={() => onTileClick(photo)}>
|
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}
|
||||||
|
|
||||||
|
{#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]}
|
{#if videoThumbs[photo.id]}
|
||||||
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
|
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
|
||||||
{:else}
|
{:else}
|
||||||
<img
|
<img
|
||||||
src={previewUrl(photo.id)}
|
src={fileThumbnailUrl(photo.id, 'preview')}
|
||||||
srcset={`${iconUrl(photo.id)} 150w, ${previewUrl(photo.id)} 400w, ${largeUrl(photo.id)} 800w`}
|
srcset={`${fileThumbnailUrl(photo.id, 'icon')} 150w, ${fileThumbnailUrl(photo.id, 'preview')} 400w, ${fileThumbnailUrl(photo.id, 'large')} 800w`}
|
||||||
sizes="(max-width: 768px) 33vw, 200px"
|
sizes="(max-width: 768px) 33vw, 200px"
|
||||||
alt={photo.name}
|
alt={photo.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -455,111 +464,19 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isVideo(photo)}
|
{#if isVideo(photo)}
|
||||||
<span class="photos__video-badge" aria-hidden="true"><Icon name="play" /></span>
|
<span class="photo-tile__video-badge" aria-hidden="true"><Icon name="play" /></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="photos__check"
|
class="photo-tile__check"
|
||||||
class:on={selected.has(photo.id)}
|
class:on={selected.has(photo.id)}
|
||||||
aria-label={t('common.select', 'Select')}
|
aria-label={t('common.select', 'Select')}
|
||||||
onclick={() => selected.toggle(photo.id)}
|
onclick={() => selected.toggle(photo.id)}
|
||||||
>
|
>
|
||||||
<Icon name="check" />
|
<Icon name="check" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
|
||||||
{/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>
|
||||||
|
{/snippet}
|
||||||
<button
|
|
||||||
class="lb__close"
|
|
||||||
aria-label={t('common.close', 'Close')}
|
|
||||||
onclick={() => (lightbox = -1)}>×</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
|
|
||||||
class="lb__tool"
|
|
||||||
class:active={lbFavorited}
|
|
||||||
title={t('common.favorite', 'Favorite')}
|
|
||||||
onclick={lbToggleFavorite}><Icon name={lbFavorited ? 'star' : 'star-outline'} /></button
|
|
||||||
>
|
|
||||||
<button class="lb__tool" title={t('common.delete', 'Delete')} onclick={lbDelete}
|
|
||||||
><Icon name="trash" /></button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lb__counter">{lightbox + 1} / {items.length}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.photos-head {
|
.photos-head {
|
||||||
@@ -567,6 +484,7 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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",
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
+59
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user