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(); 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; 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. Non-empty values gate every place * that crosses the user↔server seam (sidebar tree, timeline `path:` filter, * folder counts, heap convert) so each user sees only their own subtree. */ export function userBasePath(): string { return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, ''); } /** * 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). * * "" or "/" → BasePath (user's 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 bp = userBasePath(); const rel = uiPath.replace(/^\/+|\/+$/g, ''); if (rel === '') return bp; return bp === '' ? rel : `${bp}/${rel}`; } /** * Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the * UI can render `2024/01` instead of `users/alice/2024/01`. Paths that * are equal to the BasePath collapse to `""` (the user's root sentinel). * Paths outside the BasePath are returned as-is, but callers should * already have filtered those out via `listFolders`'s post-filter. */ export function toUserPath(serverPath: string): string { const bp = userBasePath(); const sp = serverPath.replace(/^\/+|\/+$/g, ''); if (bp === '') return sp; if (sp === bp) return ''; if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1); return sp; }