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" → "/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; }