Lets a user pick a sub-folder under their library as a working index root, stored server-side (new sidecar user_prefs table). The Library tree, reindex, and both duplicate views (stacks + cross-folder scan) now re-root to it via a single userLibraryBase() helper. Also fixes the cross-folder scan/archive endpoints, which previously walked/touched the whole originals root instead of being scoped per-user (archive now rejects out-of-scope paths, 403). Removes PhotoPrism settings (Search/Maps/Server-UI/Features/Import) that only steered PhotoPrism's own bundled SPA and were never read by mulimage's UI. Also fixes the Library tree occasionally getting stuck on "Loading folders…" by dropping gcTime:0 and gating the spinner on isLoading instead of isPending. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
241 lines
8.7 KiB
TypeScript
241 lines
8.7 KiB
TypeScript
import { browser } from '$app/environment';
|
|
import { queryClient } from '$lib/queryClient';
|
|
import type { PpClientConfig, PpSessionResponse, PpUser } from '$lib/types/photoprism';
|
|
|
|
const STORAGE_KEY = 'pp_session';
|
|
|
|
interface PersistedSession {
|
|
id: string;
|
|
accessToken: string;
|
|
previewToken: string;
|
|
downloadToken: string;
|
|
user: PpUser;
|
|
}
|
|
|
|
function loadInitial(): PersistedSession | null {
|
|
if (!browser) return null;
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return null;
|
|
return JSON.parse(raw) as PersistedSession;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single-source session state for the Svelte client. Components import the
|
|
* `session` object and read its reactive fields; mutations go through the
|
|
* helpers below. State is mirrored to localStorage so a hard refresh keeps
|
|
* the user signed in.
|
|
*/
|
|
const initial = loadInitial();
|
|
|
|
export const session = $state<{
|
|
id: string | null;
|
|
accessToken: string | null;
|
|
previewToken: string | null;
|
|
downloadToken: string | null;
|
|
user: PpUser | null;
|
|
}>({
|
|
id: initial?.id ?? null,
|
|
accessToken: initial?.accessToken ?? null,
|
|
previewToken: initial?.previewToken ?? null,
|
|
downloadToken: initial?.downloadToken ?? null,
|
|
user: initial?.user ?? null
|
|
});
|
|
|
|
export function isAuthenticated(): boolean {
|
|
return Boolean(session.accessToken);
|
|
}
|
|
|
|
export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): void {
|
|
// Drop any cached data from the prior identity before installing the
|
|
// new session. The TanStack cache is keyed on query name, not user —
|
|
// so without an explicit clear, the new login keeps showing the
|
|
// previous user's `/api/v1/config.count`, marks, folder counts, etc.
|
|
// (Hit this with the `test` user seeing the admin's library counts
|
|
// in the left sidebar.)
|
|
queryClient.clear();
|
|
// The index sub-path is per-user; drop the prior identity's value so the
|
|
// app re-roots to the new user's whole folder until the ['prefs'] query
|
|
// rehydrates it from the sidecar.
|
|
prefs.indexSubpath = '';
|
|
session.id = resp.id;
|
|
session.accessToken = resp.access_token;
|
|
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
|
|
session.downloadToken = (cfg ?? resp.config)?.downloadToken ?? '';
|
|
session.user = resp.user;
|
|
persist();
|
|
}
|
|
|
|
export function clearSession(): void {
|
|
session.id = null;
|
|
session.accessToken = null;
|
|
session.previewToken = null;
|
|
session.downloadToken = null;
|
|
session.user = null;
|
|
prefs.indexSubpath = '';
|
|
if (browser) localStorage.removeItem(STORAGE_KEY);
|
|
// Same reasoning as adoptSession — wipe the cache so the next user
|
|
// who logs in (or the login screen itself) doesn't render with the
|
|
// previous identity's data.
|
|
queryClient.clear();
|
|
}
|
|
|
|
function persist(): void {
|
|
if (!browser || !session.accessToken) return;
|
|
const payload: PersistedSession = {
|
|
id: session.id ?? '',
|
|
accessToken: session.accessToken,
|
|
previewToken: session.previewToken ?? '',
|
|
downloadToken: session.downloadToken ?? '',
|
|
user: session.user!
|
|
};
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
|
}
|
|
|
|
/**
|
|
* Build a thumbnail URL for a photo. PhotoPrism's thumb endpoint is
|
|
* /api/v1/t/:hash/:token/:size — the token is the per-session
|
|
* previewToken, which the session response provides on login.
|
|
*/
|
|
export function thumbUrl(hash: string, size = 'tile_500'): string {
|
|
if (!session.previewToken) return '';
|
|
return `/api/v1/t/${hash}/${session.previewToken}/${size}`;
|
|
}
|
|
|
|
/**
|
|
* PhotoPrism's square-cropped tile sizes (px). These are the variants
|
|
* the indexer generates by default for the `tile_*` family. fit_* exists
|
|
* for non-square sizing but is the wrong fit for grid cells with
|
|
* `object-cover` — we always render a square.
|
|
*/
|
|
const TILE_SIZES = [100, 224, 500] as const;
|
|
|
|
/**
|
|
* Pick the smallest PhotoPrism tile variant whose pixel count is at or
|
|
* above the on-screen target. Falls through to the largest (500) for
|
|
* anything bigger — we don't have a tile_720+ variant. Used by both the
|
|
* 1x and 2x slots of the srcset helper below.
|
|
*/
|
|
function pickTileSize(targetPx: number): string {
|
|
for (const s of TILE_SIZES) {
|
|
if (s >= targetPx) return `tile_${s}`;
|
|
}
|
|
return `tile_${TILE_SIZES[TILE_SIZES.length - 1]}`;
|
|
}
|
|
|
|
/**
|
|
* Build a thumbnail `srcset` for a photo at a given on-screen tile
|
|
* size. The browser picks the right variant for the current device
|
|
* pixel ratio — on a 2x display we serve `tile_500` for a 272px XL
|
|
* tile, on a 1x display the same tile gets `tile_500` only if no
|
|
* smaller variant covers it, so most users save bandwidth.
|
|
*
|
|
* Returns the `srcset` value (no `src` attribute — pair with `thumbUrl`
|
|
* for the 1x fallback). Two variants is enough: PhotoPrism only
|
|
* indexes three square sizes (100/224/500), so 1x and 2x cover the
|
|
* realistic DPR range without flooding the cache.
|
|
*/
|
|
export function thumbSrcSet(hash: string, targetPx: number): string {
|
|
if (!session.previewToken) return '';
|
|
const one = pickTileSize(targetPx);
|
|
const two = pickTileSize(targetPx * 2);
|
|
const url1x = thumbUrl(hash, one);
|
|
const url2x = thumbUrl(hash, two);
|
|
if (url1x === url2x) return `${url1x} 1x`;
|
|
return `${url1x} 1x, ${url2x} 2x`;
|
|
}
|
|
|
|
/** Companion to `thumbSrcSet` — the `src` attribute value (1x). */
|
|
export function thumbSrc(hash: string, targetPx: number): string {
|
|
if (!session.previewToken) return '';
|
|
return thumbUrl(hash, pickTileSize(targetPx));
|
|
}
|
|
|
|
/**
|
|
* Build a video stream URL. PhotoPrism's endpoint is
|
|
* /api/v1/videos/:hash/:token/:format — same previewToken as thumbnails.
|
|
* `format=avc` is the standard h264 transcode; HEVC sources are
|
|
* transcoded on first request and cached server-side.
|
|
*/
|
|
export function videoUrl(hash: string, format = 'avc'): string {
|
|
if (!session.previewToken) return '';
|
|
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`;
|
|
}
|
|
|
|
/**
|
|
* The signed-in user's library root, originals-relative, no leading/trailing
|
|
* slash. `""` means "whole library" — used today by admin accounts whose
|
|
* BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
|
|
* as set on their PhotoPrism account; the working library root the rest of
|
|
* the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
|
|
*/
|
|
export function userBasePath(): string {
|
|
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
|
|
}
|
|
|
|
/**
|
|
* Per-user "index sub-path": a folder *under* the user's BasePath that they've
|
|
* chosen as their working library root. Stored server-side by the sidecar
|
|
* (keyed by username) and hydrated into this reactive state at startup via the
|
|
* `['prefs']` query. Empty string = "whole folder" (no narrowing). Normalized
|
|
* to no leading/trailing slash.
|
|
*/
|
|
export const prefs = $state<{ indexSubpath: string }>({ indexSubpath: '' });
|
|
|
|
export function setIndexSubpathState(sub: string): void {
|
|
prefs.indexSubpath = (sub ?? '').replace(/^\/+|\/+$/g, '');
|
|
}
|
|
|
|
/**
|
|
* The effective working library root, originals-relative, no leading/trailing
|
|
* slash: the user's BasePath narrowed by their chosen index sub-path. This is
|
|
* the single point the whole app re-roots through — `toOriginalsPath` /
|
|
* `toUserPath` (and thus the sidebar tree, timeline `path:` filter, folder
|
|
* counts, folder CRUD, reindex) all derive from it. When both are empty it's
|
|
* `""` (whole library), matching the prior BasePath-only behavior.
|
|
*/
|
|
export function userLibraryBase(): string {
|
|
const bp = userBasePath();
|
|
const sub = prefs.indexSubpath;
|
|
if (sub === '') return bp;
|
|
return bp === '' ? sub : `${bp}/${sub}`;
|
|
}
|
|
|
|
/**
|
|
* Translate a user-relative path (what the sidebar and URL deal in) to a
|
|
* server-absolute, originals-relative path (what PhotoPrism's `path:`
|
|
* operator and the sidecar's filesystem ops want). Relative to the effective
|
|
* library root (`userLibraryBase()`), so the chosen index sub-path is folded
|
|
* in automatically.
|
|
*
|
|
* "" or "/" → libraryBase (user's working root)
|
|
* "2024/01" → "<libraryBase>/2024/01"
|
|
* null → "" (caller decides to omit the filter entirely)
|
|
*/
|
|
export function toOriginalsPath(uiPath: string | null): string {
|
|
if (uiPath === null) return '';
|
|
const base = userLibraryBase();
|
|
const rel = uiPath.replace(/^\/+|\/+$/g, '');
|
|
if (rel === '') return base;
|
|
return base === '' ? rel : `${base}/${rel}`;
|
|
}
|
|
|
|
/**
|
|
* Inverse of `toOriginalsPath` — strips the effective library-root prefix so
|
|
* the UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
|
|
* are equal to the root collapse to `""` (the user's root sentinel). Paths
|
|
* outside the root are returned as-is, but callers should already have
|
|
* filtered those out via `listFolders`'s post-filter.
|
|
*/
|
|
export function toUserPath(serverPath: string): string {
|
|
const base = userLibraryBase();
|
|
const sp = serverPath.replace(/^\/+|\/+$/g, '');
|
|
if (base === '') return sp;
|
|
if (sp === base) return '';
|
|
if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
|
|
return sp;
|
|
}
|