add photos, an immich-backed library behind its own sidecar

officer-photos owns the whole Immich contract: the instance URL and the API
key live there and nowhere else, and the platform side is an auth-gated
forwarder holding no credentials. The route surface is an allow-list keyed on
the first path segment, so admin, auth, api-keys, sessions, jobs, system-config
and libraries are unreachable by construction rather than by enumeration.

The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums,
people, favorites, sharing, archive, trash — because the point of a sidecar
screen is to reproduce what the upstream already ships, then extend it. The
timeline reads Immich's columnar time-bucket format directly; selection lives
in the URL per docs/navigation-audit.md.

Two things worth knowing for anyone touching this later:

- `duration` is an integer count of milliseconds in Immich 3.0. It was an
  HH:MM:SS.mmm string before, and every stale example still shows that form.
- the map container is sized with h-full/w-full, never `absolute inset-0`.
  maplibre's stylesheet sets `position: relative; overflow: hidden` on the
  element it is given, and an unlayered vendor rule beats Tailwind 4's layered
  `.absolute` regardless of source order — so the div collapses to height 0 and
  clips its own canvas away. Nothing errors: the GL context is healthy, tiles
  download and pixels are drawn into a buffer nobody ever composites.

maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file
from import.meta.url, which Officer's index.html fallback answers with HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:18:33 +00:00
co-authored by Claude Opus 5
parent 4f4e0c5dbc
commit 035a1ba8f6
42 changed files with 3429 additions and 1 deletions
+2
View File
@@ -43,6 +43,8 @@ export function App() {
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
<Route path="/invoices" element={<Dashboard.InvoicesScreen />} />
@@ -138,6 +138,7 @@ import {
ArrowDownUp,
Bitcoin,
Receipt,
Images,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
@@ -146,6 +147,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
@@ -0,0 +1,56 @@
import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /photos uses the Workspace/Panel system: the section nav (photos-nav) on the left, the section view
// (photos-view) on the right. Both talk to the officer-photos sidecar through the /api/photos auth proxy; the
// Immich URL and API key live in the sidecar's environment and the browser never sees either.
//
// The open section is :section in the URL; deeper selection (which asset, album, person, search) is in the
// query string. Nothing about "what is open" lives in a panel channel.
const ALLOWED_APP_TYPES = new Set<string | null>(['photos-nav', 'photos-view', null]);
function normalizeLayout(node: LayoutNode): LayoutNode {
if (node.type === 'panel') {
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'photos-view' };
}
const children = node.children.map((c) => {
const fixed = normalizeLayout(c.node);
return fixed === c.node ? c : { ...c, node: fixed };
});
const changed = children.some((c, i) => c !== node.children[i]);
return changed ? { ...node, children } : node;
}
export const PhotosScreen = () => {
const { section } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/photos', defaultLayout);
const workspace = useMemo(() => {
const fixed = normalizeLayout(rawWorkspace.value);
if (fixed === rawWorkspace.value) return rawWorkspace;
return { ...rawWorkspace, value: fixed };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /photos, or a section that doesn't exist, canonicalises rather than rendering a default behind a URL
// that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing.
if (!isPhotosSection(section)) {
return <Navigate to={photosSectionPath(DEFAULT_PHOTOS_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView workspace={workspace} locked />
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'photos-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'photos-nav', appType: 'photos-nav' }, size: 18 },
{ node: { type: 'panel', id: 'photos-view', appType: 'photos-view' }, size: 82 },
],
};
@@ -0,0 +1 @@
export * from './PhotosScreen';
@@ -13,6 +13,7 @@ export * from './Files';
export * from './Music';
export * from './Soulseek';
export * from './Headscale';
export * from './Photos';
export * from './Transmission';
export * from './Invoices';
export * from './Wallet';
@@ -15,6 +15,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/email'), title: 'Email' },
{ match: (p) => p.startsWith('/files'), title: 'Files' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
+19
View File
@@ -0,0 +1,19 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/photos/* — auth, then forward to officer-photos. No routes of its own and no Immich knowledge:
// this file must never grow app logic.
//
// The sidecar owns the Immich contract and holds its API key.
const proxy = createSidecarProxy({
name: 'photos',
prefix: '/api/photos',
// Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the
// socket can sit quiet longer than the default 60s idle drop allows.
timeoutSeconds: 600,
});
export const photosRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getPhotosServerUrl = proxy.getHttpUrl;
+2
View File
@@ -26,6 +26,7 @@ import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router';
import { transmissionRouter } from './api/transmission/router';
import { invoiceshelfRouter } from './api/invoiceshelf/router';
import { photosRouter } from './api/photos/router';
import { walletRouter } from './api/wallet/router';
import { vpnRouter } from './api/vpn/router';
import { terminalRouter } from './api/terminal/sidecar-server';
@@ -118,6 +119,7 @@ protectedRouter.route('/notify', notifyRouter);
protectedRouter.route('/headscale', headscaleRouter);
protectedRouter.route('/transmission', transmissionRouter);
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
protectedRouter.route('/photos', photosRouter);
protectedRouter.route('/wallet', walletRouter);
protectedRouter.route('/vpn', vpnRouter);
protectedRouter.route('/system-monitor', systemMonitorRouter);
+137
View File
@@ -0,0 +1,137 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleOfficerRoute } from './routes';
import { callUpstream, getBase, getConfig } from './upstream';
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
// credentials.
//
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
//
// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated).
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
//
// GET /_health ours. Confirms the key is live and reports the Immich version and who the key is.
// * /_officer/<path> forwarded to <IMMICH_URL>/api/<path>, first-segment allow-list (routes.ts)
// anything else 404
//
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Uploads go through this proxy as multipart bodies, and a phone's video is not small.
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const cfg = getConfig();
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 });
const started = Date.now();
try {
// Version is public, so it separates "instance down" from "key rejected" in one shot.
const [versionRes, meRes] = await Promise.all([
callUpstream(cfg, { path: '/api/server/version', withKey: false }),
callUpstream(cfg, { path: '/api/users/me' }),
]);
if (!versionRes.ok) {
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
}
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number')
? `${v.major}.${v.minor}.${v.patch}`
: null;
if (!meRes.ok) {
return Response.json(
{ ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started },
{ status: 502 },
);
}
const me = (await meRes.json()) as { email?: string; name?: string };
return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started });
} catch (err) {
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
}
}
if (url.pathname.startsWith('/_officer/')) {
if (!cfg) return Response.json({ error: 'photos not configured' }, { status: 503 });
try {
const res = await handleOfficerRoute(cfg, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'photos',
capabilities: ['photos'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'photos:server', port });
console.log(`[photos] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[photos] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+108
View File
@@ -0,0 +1,108 @@
import type { UpstreamConfig } from './upstream';
import { callUpstream } from './upstream';
// The Officer-owned route surface. This is an allow-list, not a passthrough.
//
// Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar
// adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and
// (c) refusing to expose the administrative half of the API at all.
//
// The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a
// 404 before a request is made, so the deny side is closed by construction rather than by enumeration.
//
// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and
// password machinery — the key is the credential here, and nothing should be minting sessions through a
// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/*
// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external
// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it.
// Adding one should be a decision, not an accident.
const RESOURCES: Record<string, readonly string[]> = {
// The library itself.
assets: ['GET', 'POST', 'PUT', 'DELETE'],
albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
timeline: ['GET'],
memories: ['GET', 'POST', 'PUT', 'DELETE'],
people: ['GET', 'POST', 'PUT'],
faces: ['GET', 'PUT'],
tags: ['GET', 'POST', 'PUT', 'DELETE'],
stacks: ['GET', 'POST', 'PUT', 'DELETE'],
// Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body.
search: ['GET', 'POST'],
duplicates: ['GET'],
map: ['GET'],
view: ['GET'],
// Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip.
download: ['POST'],
// Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is
// the counterpart of a delete the UI can already perform, not because it is safe.
trash: ['GET', 'POST'],
// Sharing and social. Read-mostly, but a shared link is the point of them.
'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'],
activities: ['GET', 'POST', 'DELETE'],
partners: ['GET'],
notifications: ['GET', 'PUT', 'DELETE'],
// Read-only context: who the key acts as, and what the server is.
users: ['GET'],
server: ['GET'],
};
// Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a
// thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either.
const PASSTHROUGH_HEADERS = [
'content-type',
'content-length',
'content-disposition',
'content-range',
'accept-ranges',
'etag',
'last-modified',
'cache-control',
] as const;
/**
* Forward one request to Immich and stream the answer back.
*
* The body is a stream, never an ArrayBuffer: a full-resolution original or a `download/archive` zip is
* hundreds of megabytes and buffering it would hold all of it in the sidecar's heap for no reason.
*/
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
const rest = url.pathname.slice('/_officer/'.length);
if (!rest) return null;
const [resource] = rest.split('/');
const allowedMethods = resource ? RESOURCES[resource] : undefined;
if (!allowedMethods) return null;
if (!allowedMethods.includes(req.method)) {
return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 });
}
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
// An <img>/<video> cannot send an Authorization header, so the browser puts Officer's JWT in `?token=`
// (userMiddleware accepts it there). That token is Officer's, not Immich's: forwarding it would write the
// owner's session credential into Immich's access log for every thumbnail. Drop it at the boundary.
const query = new URLSearchParams(url.search);
query.delete('token');
const search = query.toString();
const res = await callUpstream(cfg, {
path: `/api/${rest}`,
method: req.method,
query: search ? `?${search}` : '',
body: hasBody ? await req.arrayBuffer() : null,
contentType: req.headers.get('content-type'),
range: req.headers.get('range'),
ifNoneMatch: req.headers.get('if-none-match'),
});
const headers = new Headers();
for (const name of PASSTHROUGH_HEADERS) {
const value = res.headers.get(name);
if (value) headers.set(name, value);
}
// 204 and 304 must not carry a body, and Bun throws if one is attached.
const body = res.status === 204 || res.status === 304 ? null : res.body;
return new Response(body, { status: res.status, headers });
}
+85
View File
@@ -0,0 +1,85 @@
// Immich upstream config for the officer-photos sidecar.
//
// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring
// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy
// and holds NO Immich credentials.
//
// Two things about Immich's API are load-bearing:
//
// 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
// 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
// credential. Create the key with all permissions unless there is a reason not to.
// 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
// credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
const { IMMICH_URL, IMMICH_API_KEY } = process.env;
export type UpstreamConfig = { base: string; key: string };
let warnedUnset = false;
/** The instance URL alone, for logging — set without a key is a real state and should read as one. */
export function getBase(): string | null {
return IMMICH_URL?.trim().replace(/\/+$/, '') || null;
}
/**
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
*/
export function getConfig(): UpstreamConfig | null {
const base = IMMICH_URL?.trim().replace(/\/+$/, '');
const key = IMMICH_API_KEY?.trim();
if (!base || !key) {
if (!warnedUnset) {
const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and ');
console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`);
warnedUnset = true;
}
return null;
}
return { base, key };
}
type CallOptions = {
/** Absolute path on the Immich host, e.g. `/api/albums`. */
path: string;
method?: string;
/** Raw search string including the leading `?`, or empty. */
query?: string;
body?: BodyInit | null;
contentType?: string | null;
/** Byte range for thumbnail/original/video reads, forwarded verbatim. */
range?: string | null;
ifNoneMatch?: string | null;
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
withKey?: boolean;
};
/** The single door to Immich. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body ?? undefined,
// A redirect from an API route means something has gone wrong with auth; surface it rather than
// following it into an HTML page.
redirect: 'manual',
});
if (res.status === 401 || res.status === 403) {
// 403 is the interesting one: the key is valid but lacks the permission this route needs.
console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path}${res.status} (key rejected or under-scoped)`);
}
return res;
}
+2
View File
@@ -73,6 +73,8 @@ export type SidecarEvent =
| { type: 'transmission:server'; port: number }
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'invoiceshelf:server'; port: number }
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'photos:server'; port: number }
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'wallet:server'; port: number }
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
@@ -10,6 +10,7 @@ import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
@@ -30,6 +31,7 @@ const apps = [
...musicMetas,
...soulseekMetas,
...headscaleMetas,
...photosMetas,
...transmissionMetas,
...invoicesMetas,
...walletMetas,
@@ -0,0 +1,172 @@
import { useMemo } from 'react';
import { Link, useSearchParams } from 'react-router';
import { ArrowLeft, ImageOff, MoreVertical, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { PhotoGrid } from './PhotoGrid';
import { SelectionBar } from './SelectionBar';
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
import { useAlbum, useAlbums } from './usePhotosData';
import { useAssetActions } from './useAssetActions';
import { useSelection } from './useSelection';
// Two states in one section, switched by `?album=<id>`: the wall of album covers, and one album's photos.
// A separate `/photos/albums/:id` route would work too, but the album is a selection inside the section — the
// same shape as `?selected=` elsewhere in the app — and this keeps the nav highlight where it belongs.
export const AlbumsSection = () => {
const [params] = useSearchParams();
const albumId = params.get(ALBUM_PARAM);
return albumId ? <AlbumDetailView albumId={albumId} /> : <AlbumListView />;
};
const AlbumListView = () => {
const { token } = useClient();
const { data: albums, isLoading } = useAlbums();
const { createAlbum, deleteAlbum, renameAlbum } = useAssetActions();
const sorted = useMemo(() => [...(albums ?? [])].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), [albums]);
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">Albums</h1>
<span className="text-xs text-muted-foreground">{sorted.length}</span>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
onClick={() => {
const albumName = window.prompt('New album name');
if (albumName) createAlbum.mutate({ albumName });
}}
>
<Plus className="mr-1.5 h-4 w-4" />
Create album
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && sorted.length === 0 && <p className="text-sm text-muted-foreground">No albums yet.</p>}
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
{sorted.map((album) => (
<div key={album.id} className="group relative">
<Link to={`?${ALBUM_PARAM}=${album.id}`} className="block">
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
{album.albumThumbnailAssetId ? (
<img
src={thumbUrl(album.albumThumbnailAssetId, token)}
alt=""
loading="lazy"
className="h-full w-full object-cover transition group-hover:brightness-110"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<ImageOff className="h-8 w-8" />
</div>
)}
</div>
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
<p className="truncate text-xs text-muted-foreground">
{album.assetCount} items
{album.shared ? ' · shared' : ''}
</p>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-7 w-7 bg-black/50 text-white opacity-0 group-hover:opacity-100"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
const albumName = window.prompt('Rename album', album.albumName);
if (albumName) renameAlbum.mutate({ albumId: album.id, albumName });
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => window.confirm(`Delete "${album.albumName}"?`) && deleteAlbum.mutate(album.id)}
>
Delete album
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
</div>
</div>
);
};
const AlbumDetailView = ({ albumId }: { albumId: string }) => {
const { token } = useClient();
const [params] = useSearchParams();
const { data: album, isLoading } = useAlbum(albumId);
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
const selection = useSelection(assets.map((asset) => asset.id));
const back = (() => {
const next = new URLSearchParams(params);
next.delete(ALBUM_PARAM);
const search = next.toString();
return search ? `?${search}` : '?';
})();
const range = formatDateRange(album?.startDate, album?.endDate);
return (
<div className="flex h-full flex-col">
{selection.isSelecting ? (
<SelectionBar selection={selection} albumId={albumId} />
) : (
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button asChild variant="ghost" size="icon" title="Back to albums">
<Link to={back}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold">{album?.albumName ?? 'Album'}</h1>
<p className="truncate text-xs text-muted-foreground">
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
</p>
</div>
</header>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{isLoading ? (
<p className="p-2 text-sm text-muted-foreground">Loading</p>
) : (
<PhotoGrid
assets={assets}
token={token}
selection={selection}
empty={<p className="p-2 text-sm text-muted-foreground">This album is empty.</p>}
/>
)}
</div>
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
</div>
);
};
@@ -0,0 +1,101 @@
import type { Asset } from './shared';
import { Link } from 'react-router';
import { Camera, Image as ImageIcon, MapPin } from 'lucide-react';
import { PERSON_PARAM, formatBytes, formatLongDate, formatTime, personThumbUrl, photosSectionPath } from './shared';
// The lightbox's info drawer, in the order Immich's own panel uses: description, date, file, camera, location,
// people. Sections with nothing in them are omitted rather than shown empty — a library full of screenshots
// has no camera data and shouldn't render a row of dashes for every one.
type AssetInfoPanelProps = { asset: Asset | undefined; token: string | null };
export const AssetInfoPanel = ({ asset, token }: AssetInfoPanelProps) => {
if (!asset) return <div className="p-4 text-sm text-muted-foreground">Loading</div>;
const exif = asset.exifInfo;
const megapixels = asset.width && asset.height ? (asset.width * asset.height) / 1_000_000 : 0;
const place = [exif?.city, exif?.state, exif?.country].filter(Boolean).join(', ');
const aperture = exif?.fNumber ? `ƒ/${exif.fNumber}` : null;
const shot = [
aperture,
exif?.exposureTime ? `${exif.exposureTime}s` : null,
exif?.focalLength ? `${Math.round(exif.focalLength)}mm` : null,
exif?.iso ? `ISO ${exif.iso}` : null,
].filter(Boolean);
return (
<div className="space-y-5 p-4 text-sm">
{exif?.description && <p className="text-muted-foreground">{exif.description}</p>}
<section>
<p className="font-medium">{formatLongDate(asset.localDateTime)}</p>
<p className="text-xs text-muted-foreground">{formatTime(asset.localDateTime)}</p>
</section>
<Section icon={<ImageIcon className="h-4 w-4" />} title={asset.originalFileName}>
<Row
label={`${asset.width} × ${asset.height}`}
value={megapixels >= 0.1 ? `${megapixels.toFixed(1)} MP` : null}
/>
<Row label="Size" value={formatBytes(exif?.fileSizeInByte)} />
<Row label="Type" value={asset.originalMimeType ?? asset.type} />
<p className="break-all pt-1 text-xs text-muted-foreground">{asset.originalPath}</p>
</Section>
{(exif?.make || exif?.model || shot.length > 0) && (
<Section icon={<Camera className="h-4 w-4" />} title={[exif?.make, exif?.model].filter(Boolean).join(' ')}>
{exif?.lensModel && <p className="text-xs text-muted-foreground">{exif.lensModel}</p>}
{shot.length > 0 && <p className="text-xs text-muted-foreground">{shot.join(' · ')}</p>}
</Section>
)}
{(place || (exif?.latitude != null && exif?.longitude != null)) && (
<Section icon={<MapPin className="h-4 w-4" />} title={place || 'Location'}>
{exif?.latitude != null && exif?.longitude != null && (
<p className="text-xs text-muted-foreground">
{exif.latitude.toFixed(5)}, {exif.longitude.toFixed(5)}
</p>
)}
</Section>
)}
{asset.people && asset.people.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">People</h3>
<div className="flex flex-wrap gap-3">
{asset.people.map((person) => (
<Link
key={person.id}
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
className="w-16 text-center"
>
<img
src={personThumbUrl(person.id, token)}
alt=""
className="h-16 w-16 rounded-full object-cover ring-1 ring-white/10"
/>
<span className="mt-1 block truncate text-xs">{person.name || 'Unnamed'}</span>
</Link>
))}
</div>
</section>
)}
</div>
);
};
type SectionProps = { icon: React.ReactNode; title: string; children?: React.ReactNode };
const Section = ({ icon, title, children }: SectionProps) => (
<section className="flex gap-3">
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{title}</p>
{children}
</div>
</section>
);
const Row = ({ label, value }: { label: string; value: string | null }) => (
<p className="text-xs text-muted-foreground">{value ? `${label} · ${value}` : label}</p>
);
@@ -0,0 +1,107 @@
import type { GridAsset } from './shared';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { CheckCircle2, Circle, Heart, Layers, Play } from 'lucide-react';
import { ASSET_PARAM, formatDuration, thumbUrl } from './shared';
// One tile in the justified grid.
//
// The tile is a real <Link> to `?asset=<id>` — the lightbox is addressable, so a photo can be cmd-clicked
// into a tab, linked to, and closed with the back button. The select checkbox is a sibling <button> layered
// over it, never nested inside the anchor: a button inside an anchor is invalid HTML and swallows the
// keyboard behaviour of both.
type AssetTileProps = {
asset: GridAsset;
token: string | null;
width: number;
height: number;
selected: boolean;
/** True once anything is selected — the checkbox stops being hover-only and the click selects, not opens. */
selecting: boolean;
onToggle: (id: string, ev: React.MouseEvent) => void;
};
export const AssetTile = ({ asset, token, width, height, selected, selecting, onToggle }: AssetTileProps) => {
const [params] = useSearchParams();
const [loaded, setLoaded] = useState(false);
const duration = formatDuration(asset.duration);
const href = (() => {
const next = new URLSearchParams(params);
next.set(ASSET_PARAM, asset.id);
return `?${next.toString()}`;
})();
const badges = (
<>
{asset.isFavorite && <Heart className="absolute bottom-1.5 left-1.5 h-4 w-4 fill-white text-white drop-shadow" />}
{asset.stackCount > 1 && (
<span className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded bg-black/60 px-1 text-[10px] font-medium text-white">
<Layers className="h-3 w-3" />
{asset.stackCount}
</span>
)}
{!asset.isImage && (
<span className="absolute bottom-1.5 right-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
<Play className="h-2.5 w-2.5 fill-white" />
{duration ?? ''}
</span>
)}
</>
);
const inner = (
<>
{/* A neutral block until the thumbnail decodes, so the row doesn't flash white on a dark page. */}
<div className={`absolute inset-0 bg-white/5 transition-opacity ${loaded ? 'opacity-0' : 'opacity-100'}`} />
<img
src={thumbUrl(asset.id, token)}
alt=""
loading="lazy"
decoding="async"
draggable={false}
onLoad={() => setLoaded(true)}
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
/>
{badges}
</>
);
return (
<div className="relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
{selecting ? (
// Once a selection exists, a plain click extends it rather than navigating — matching Immich, where
// you can rubber-band through a month without the lightbox opening on every tile.
<button
type="button"
onClick={(ev) => onToggle(asset.id, ev)}
className="group absolute inset-0 block h-full w-full cursor-pointer"
>
{inner}
</button>
) : (
<Link to={href} className="group absolute inset-0 block h-full w-full">
{inner}
</Link>
)}
<button
type="button"
onClick={(ev) => onToggle(asset.id, ev)}
title={selected ? 'Deselect' : 'Select'}
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
selected ? 'opacity-100' : 'opacity-0 hover:opacity-100 focus-visible:opacity-100'
} ${selecting ? 'opacity-100' : ''}`}
>
{selected ? (
<CheckCircle2 className="h-5 w-5 fill-primary text-white" />
) : (
<Circle className="h-5 w-5 drop-shadow" />
)}
</button>
{selected && <span className="pointer-events-none absolute inset-0 rounded-sm ring-2 ring-inset ring-primary" />}
</div>
);
};
@@ -0,0 +1,254 @@
import type { Asset } from './shared';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Download,
Heart,
Info,
Trash2,
Undo2,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ASSET_PARAM, originalUrl, thumbUrl, videoUrl } from './shared';
import { AssetInfoPanel } from './AssetInfoPanel';
import { useAsset } from './usePhotosData';
import { useAssetActions } from './useAssetActions';
// The lightbox. Which asset is open lives in `?asset=<id>`, so it is linkable, survives a reload, and closes
// with the browser's back button — the rule from docs/navigation-audit.md applied to the one piece of this
// screen that most wants to be a modal with a useState behind it.
type AssetViewerProps = {
/** Every asset currently on screen, in display order — this is what prev/next walk. */
ids: string[];
token: string | null;
/** Trash shows restore/delete-forever instead of archive/delete. */
inTrash?: boolean;
};
export const AssetViewer = ({ ids, token, inTrash = false }: AssetViewerProps) => {
const [params, setParams] = useSearchParams();
const [showInfo, setShowInfo] = useState(false);
const openId = params.get(ASSET_PARAM);
const { data: asset } = useAsset(openId);
const index = openId ? ids.indexOf(openId) : -1;
const prev = index > 0 ? ids[index - 1] : undefined;
const next = index >= 0 && index < ids.length - 1 ? ids[index + 1] : undefined;
const goTo = useCallback(
(id: string | undefined) => {
if (!id) return;
setParams(
(current) => {
const nextParams = new URLSearchParams(current);
nextParams.set(ASSET_PARAM, id);
return nextParams;
},
{ replace: true },
);
},
[setParams],
);
// Replace, not push: stepping through fifty photos shouldn't bury the grid fifty entries deep in history.
// Opening the viewer pushed one entry, so one Back still returns to where you were.
const close = useCallback(() => {
setParams((current) => {
const nextParams = new URLSearchParams(current);
nextParams.delete(ASSET_PARAM);
return nextParams;
});
}, [setParams]);
useEffect(() => {
if (!openId) return;
const onKey = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') close();
else if (ev.key === 'ArrowLeft') goTo(prev);
else if (ev.key === 'ArrowRight') goTo(next);
else if (ev.key === 'i') setShowInfo((value) => !value);
else return;
ev.preventDefault();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [openId, prev, next, close, goTo]);
if (!openId) return null;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
<ViewerHeader
asset={asset}
id={openId}
token={token}
inTrash={inTrash}
showInfo={showInfo}
onToggleInfo={() => setShowInfo((value) => !value)}
onClose={close}
onAdvance={() => (next ? goTo(next) : close())}
/>
<div className="flex min-h-0 flex-1">
<div className="relative flex min-w-0 flex-1 items-center justify-center">
<NavButton side="left" disabled={!prev} onClick={() => goTo(prev)} />
{asset?.type === 'VIDEO' ? (
<video
key={openId}
src={videoUrl(openId, token)}
poster={thumbUrl(openId, token, 'preview')}
controls
autoPlay
className="max-h-full max-w-full"
/>
) : (
// `preview` rather than `original`: originals are frequently 30MB HEIC/RAW the browser may not even
// decode, and Immich's own viewer shows the preview for the same reason. Download gets the original.
<img
key={openId}
src={thumbUrl(openId, token, 'preview')}
alt={asset?.originalFileName ?? ''}
className="max-h-full max-w-full object-contain"
/>
)}
<NavButton side="right" disabled={!next} onClick={() => goTo(next)} />
</div>
{showInfo && (
<aside className="w-80 shrink-0 overflow-y-auto border-l border-white/10 bg-background">
<AssetInfoPanel asset={asset} token={token} />
</aside>
)}
</div>
</div>
);
};
type NavButtonProps = { side: 'left' | 'right'; disabled: boolean; onClick: () => void };
const NavButton = ({ side, disabled, onClick }: NavButtonProps) => (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label={side === 'left' ? 'Previous' : 'Next'}
className={`absolute ${side === 'left' ? 'left-2' : 'right-2'} z-10 rounded-full bg-black/40 p-2 text-white transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-0`}
>
{side === 'left' ? <ChevronLeft className="h-6 w-6" /> : <ChevronRight className="h-6 w-6" />}
</button>
);
type ViewerHeaderProps = {
asset: Asset | undefined;
id: string;
token: string | null;
inTrash: boolean;
showInfo: boolean;
onToggleInfo: () => void;
onClose: () => void;
onAdvance: () => void;
};
const ViewerHeader = ({ asset, id, token, inTrash, showInfo, onToggleInfo, onClose, onAdvance }: ViewerHeaderProps) => {
const { setFavorite, setArchived, trash, restore, deleteForever } = useAssetActions();
const archived = asset?.visibility === 'archive';
// Anything that removes the asset from the current view advances to the next one first — otherwise the
// viewer is left pointing at an id that no longer exists in the grid behind it.
const removeThen = (run: () => void) => {
run();
onAdvance();
};
return (
<header className="flex items-center gap-1 px-2 py-2 text-white">
<Button variant="ghost" size="icon" onClick={onClose} title="Close (Esc)" className="text-white">
<X className="h-5 w-5" />
</Button>
<span className="ml-1 min-w-0 flex-1 truncate text-sm text-white/70">{asset?.originalFileName}</span>
{inTrash ? (
<>
<Button
variant="ghost"
size="icon"
title="Restore"
className="text-white"
onClick={() => removeThen(() => restore.mutate([id]))}
>
<Undo2 className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete permanently"
className="text-white"
onClick={() => removeThen(() => deleteForever.mutate([id]))}
>
<Trash2 className="h-5 w-5" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
title={asset?.isFavorite ? 'Remove from favorites' : 'Favorite'}
className="text-white"
onClick={() => setFavorite.mutate({ ids: [id], isFavorite: !asset?.isFavorite })}
>
<Heart className={`h-5 w-5 ${asset?.isFavorite ? 'fill-white' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
title={archived ? 'Unarchive' : 'Archive'}
className="text-white"
onClick={() => removeThen(() => setArchived.mutate({ ids: [id], archived: !archived }))}
>
{archived ? <ArchiveRestore className="h-5 w-5" /> : <Archive className="h-5 w-5" />}
</Button>
<Button
variant="ghost"
size="icon"
title="Move to trash"
className="text-white"
onClick={() => removeThen(() => trash.mutate([id]))}
>
<Trash2 className="h-5 w-5" />
</Button>
</>
)}
<a
href={originalUrl(id, token)}
download={asset?.originalFileName ?? ''}
title="Download original"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white hover:bg-white/10"
>
<Download className="h-5 w-5" />
</a>
<Button
variant="ghost"
size="icon"
title="Info (i)"
className={`text-white ${showInfo ? 'bg-white/15' : ''}`}
onClick={onToggleInfo}
>
<Info className="h-5 w-5" />
</Button>
</header>
);
};
@@ -0,0 +1,94 @@
import { Link } from 'react-router';
import { useClient } from 'hooks/useClient';
import { PERSON_PARAM, QUERY_PARAM, personThumbUrl, photosSectionPath, thumbUrl } from './shared';
import { useExplore, usePeople } from './usePhotosData';
// Explore: the faces row, then whatever Immich has clustered the library by — places and things.
//
// Immich's own explore tiles link to a structured search (`{"city":"Lisbon"}`). This proxy exposes smart and
// metadata search only, so a tile links to a smart search for its label instead. Close in spirit, and honest
// about it: a city tile finds photos that LOOK like that city rather than ones tagged with it.
const SECTION_TITLES: Record<string, string> = {
'exifInfo.city': 'Places',
'exifInfo.country': 'Countries',
'smartInfo.objects': 'Things',
'exifInfo.state': 'Regions',
};
export const ExploreSection = () => {
const { token } = useClient();
const { data: explore, isLoading } = useExplore();
const { data: people } = usePeople();
const faces = (people?.people ?? []).filter((person) => person.name).slice(0, 12);
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">Explore</h1>
</header>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
{faces.length > 0 && (
<section>
<div className="mb-2 flex items-baseline gap-2">
<h2 className="text-sm font-semibold">People</h2>
<Link to={photosSectionPath('people')} className="text-xs text-muted-foreground hover:underline">
View all
</Link>
</div>
<div className="flex flex-wrap gap-4">
{faces.map((person) => (
<Link
key={person.id}
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
className="w-20 text-center"
>
<img
src={personThumbUrl(person.id, token)}
alt=""
loading="lazy"
className="h-20 w-20 rounded-full object-cover ring-1 ring-white/10"
/>
<span className="mt-1 block truncate text-xs">{person.name}</span>
</Link>
))}
</div>
</section>
)}
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{(explore ?? []).map((section) => (
<section key={section.fieldName}>
<h2 className="mb-2 text-sm font-semibold">{SECTION_TITLES[section.fieldName] ?? section.fieldName}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{section.items.map((item) => (
<Link
key={`${section.fieldName}:${item.value}`}
to={`${photosSectionPath('search')}?${QUERY_PARAM}=${encodeURIComponent(item.value)}`}
className="group relative aspect-square overflow-hidden rounded-lg bg-white/5"
>
<img
src={thumbUrl(item.data.id, token)}
alt=""
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-105"
/>
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 text-sm font-medium text-white">
{item.value}
</span>
</Link>
))}
</div>
</section>
))}
{!isLoading && (explore ?? []).length === 0 && faces.length === 0 && (
<p className="text-sm text-muted-foreground">Nothing to explore yet.</p>
)}
</div>
</div>
);
};
@@ -0,0 +1,371 @@
import type { GeoJSONSource } from 'maplibre-gl';
import type { MapFilter } from './usePhotosData';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Button } from '@/components/ui/button';
import { useColorMode } from '@/components/ui/ThemeProvider';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { ASSET_PARAM, thumbUrl } from './shared';
import { useMapMarkers, useServerConfig } from './usePhotosData';
// The map, as close to Immich's as the pieces allow.
//
// Three things are worth knowing before changing this:
//
// 1. The BASEMAP is the only thing on /photos the browser fetches from outside Officer. Its URL comes from
// Immich's own `/server/config` (proxied), but the vector tiles themselves come straight from
// tiles.immich.cloud — exactly as Immich's web app does it. Proxying a basemap would mean relaying
// thousands of tile requests that contain none of the owner's data, through a sidecar whose job is the
// library. The tile server learns which parts of the world are being looked at and nothing else.
// 2. CLUSTERING is maplibre's, not ours. A geotagged library is tens of thousands of pins, and the GeoJSON
// source does the bucketing on a worker thread. That is also why markers are fetched in one unbucketed
// request: a marker is six numbers, and clustering needs all of them at once to be correct.
// 3. The photo THUMBNAILS are DOM markers layered over an invisible-by-occlusion circle layer, capped at
// THUMB_LIMIT and rebuilt on `idle`. Rendering an image per asset into the GL context would mean
// uploading thousands of textures; the browser only ever holds the ones actually on screen.
//
// maplibre-gl is PINNED TO 5.x on purpose. In 6.0 the tile worker became a separate `maplibre-gl-worker.mjs`
// resolved at runtime from `import.meta.url`. Officer bundles the SPA and serves an index.html fallback for
// unknown paths, so that request comes back as HTML and the browser refuses it — "non-JavaScript MIME type of
// text/html", and no map at all. 5.x inlines the worker as a blob, which needs no serving infrastructure.
// Upgrading means either copying maplibre's worker + shared chunks into public/ at build time or calling
// `setWorkerUrl()`; do not bump the major without doing one of those.
const SOURCE = 'photos-assets';
const CLUSTER_LAYER = 'photos-clusters';
const COUNT_LAYER = 'photos-cluster-count';
const POINT_LAYER = 'photos-points';
/** How many photo thumbnails to draw at once. Past this it is a wall of images, not a map. */
const THUMB_LIMIT = 120;
const ACCENT = '#10b981';
type PointFeature = {
type: 'Feature';
geometry: { type: 'Point'; coordinates: [number, number] };
properties: { id: string };
};
type PointCollection = { type: 'FeatureCollection'; features: PointFeature[] };
type BoolFilterKey = 'isFavorite' | 'isArchived' | 'withPartners' | 'withSharedAlbums';
const TOGGLES: { key: BoolFilterKey; label: string; title: string }[] = [
{ key: 'isFavorite', label: 'Favorites', title: 'Only favorited photos' },
{ key: 'isArchived', label: 'Archived', title: 'Include archived photos' },
{ key: 'withPartners', label: 'Partners', title: "Include partners' photos" },
{ key: 'withSharedAlbums', label: 'Shared', title: 'Include photos from shared albums' },
];
/**
* A marker is a plain DOM button rather than JSX, because maplibre owns the element's position and React must
* not re-parent it. Styles are inline for the same reason Tailwind is avoided here: the element is created
* outside the render tree, so keeping its appearance next to its construction is the readable choice.
*/
const makeThumbElement = (id: string, token: string | null, onOpen: (id: string) => void) => {
const el = document.createElement('button');
el.type = 'button';
el.title = 'Open photo';
el.style.cssText =
'width:46px;height:46px;padding:0;border-radius:9999px;border:2px solid rgba(255,255,255,.85);' +
'box-shadow:0 2px 8px rgba(0,0,0,.45);background-color:rgba(0,0,0,.35);background-size:cover;' +
'background-position:center;cursor:pointer;display:block';
el.style.backgroundImage = `url("${thumbUrl(id, token)}")`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
onOpen(id);
});
return el;
};
export const MapSection = () => {
const { token } = useClient();
const { colorMode } = useColorMode();
const [, setParams] = useSearchParams();
const [filter, setFilter] = useState<MapFilter>({ withPartners: true, withSharedAlbums: true });
const { data: config } = useServerConfig();
const { data: markers, isLoading, error: markersError } = useMapMarkers(filter);
const styleUrl = colorMode === 'dark' ? config?.mapDarkStyleUrl : config?.mapLightStyleUrl;
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<MapLibreMap | null>(null);
const thumbsRef = useRef<Marker[]>([]);
const shownRef = useRef('');
const loadedRef = useRef(false);
const fittedRef = useRef(false);
const [ready, setReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [visibleIds, setVisibleIds] = useState<string[]>([]);
const open = useCallback(
(id: string) => {
setParams((current) => {
const next = new URLSearchParams(current);
next.set(ASSET_PARAM, id);
return next;
});
},
[setParams],
);
const data = useMemo<PointCollection>(
() => ({
type: 'FeatureCollection',
features: (markers ?? []).map((marker) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
properties: { id: marker.id },
})),
}),
[markers],
);
// Build the map. Keyed on the style URL, so flipping Officer's light/dark mode rebuilds it against Immich's
// matching basemap — cheaper to reason about than swapping the style and re-adding every source by hand.
useEffect(() => {
const container = containerRef.current;
if (!container || !styleUrl) return;
loadedRef.current = false;
fittedRef.current = false;
setMapError(null);
const map = new MapLibreMap({
container,
style: styleUrl,
center: [0, 25],
zoom: 1.2,
attributionControl: { compact: true },
});
map.addControl(new NavigationControl({ showCompass: false }), 'top-right');
// Immich's basemap style asks for icons its own sprite sheet doesn't ship — `places_locality` selects
// `capital` for capital cities, and none of the four sprite variants contain it (`townspot` is there).
// That is upstream's bug, visible in Immich's own web map too, and it costs nothing but a console warning
// per tile batch. Handing maplibre a blank keeps the log readable; the label still draws, just iconless.
map.on('styleimagemissing', (ev) => {
if (!map.hasImage(ev.id)) map.addImage(ev.id, { width: 1, height: 1, data: new Uint8Array(4) });
});
map.on('load', () => {
loadedRef.current = true;
setReady(true);
});
// Registering ANY `error` listener turns OFF maplibre's own console logging, so log it back out. Only
// pre-load failures are banner-worthy — a single tile 404 after that would make a working map look
// broken — but swallowing the rest leaves a blank map above an empty console, which cannot be debugged.
map.on('error', (ev) => {
console.error('[photos/map]', ev.error ?? ev);
if (!loadedRef.current) setMapError(ev.error?.message ?? 'The basemap could not be loaded.');
});
mapRef.current = map;
return () => {
thumbsRef.current.forEach((marker) => marker.remove());
thumbsRef.current = [];
shownRef.current = '';
map.remove();
mapRef.current = null;
setReady(false);
};
}, [styleUrl]);
// Feed the markers in, adding the layers the first time and only swapping data after that.
useEffect(() => {
const map = mapRef.current;
if (!map || !ready) return;
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined;
if (existing) {
existing.setData(data);
} else {
map.addSource(SOURCE, { type: 'geojson', data, cluster: true, clusterRadius: 60, clusterMaxZoom: 15 });
map.addLayer({
id: CLUSTER_LAYER,
type: 'circle',
source: SOURCE,
filter: ['has', 'point_count'],
paint: {
'circle-color': ACCENT,
'circle-opacity': 0.85,
'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 100, 26, 1000, 34],
'circle-stroke-width': 2,
'circle-stroke-color': 'rgba(255,255,255,.75)',
},
});
map.addLayer({
id: COUNT_LAYER,
type: 'symbol',
source: SOURCE,
filter: ['has', 'point_count'],
// Immich's own style ships this fontstack; anything else would 404 and silently drop the numbers.
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['Noto Sans Medium'],
'text-size': 12,
},
paint: { 'text-color': '#05231b' },
});
map.addLayer({
id: POINT_LAYER,
type: 'circle',
source: SOURCE,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-color': ACCENT,
'circle-radius': 6,
'circle-stroke-width': 2,
'circle-stroke-color': 'rgba(255,255,255,.8)',
},
});
map.on('click', CLUSTER_LAYER, (ev) => {
const feature = ev.features?.[0];
const clusterId = feature?.properties?.cluster_id;
if (feature?.geometry.type !== 'Point' || typeof clusterId !== 'number') return;
const [lon, lat] = feature.geometry.coordinates;
const source = map.getSource(SOURCE) as GeoJSONSource | undefined;
void source?.getClusterExpansionZoom(clusterId).then((zoom) => map.easeTo({ center: [lon, lat], zoom }));
});
map.on('click', POINT_LAYER, (ev) => {
const id = ev.features?.[0]?.properties?.id;
if (typeof id === 'string') open(id);
});
for (const layer of [CLUSTER_LAYER, POINT_LAYER]) {
map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
}
}
// Frame the library once. Re-fitting on every filter change would yank the view out from under someone
// who just zoomed into a city and then unticked "archived".
if (!fittedRef.current && data.features.length > 0) {
const bounds = new LngLatBounds();
for (const feature of data.features) bounds.extend(feature.geometry.coordinates);
map.fitBounds(bounds, { padding: 64, maxZoom: 12, animate: false });
fittedRef.current = true;
}
}, [ready, data, open]);
// Swap the thumbnail markers whenever the map settles. `idle` rather than `move`, so a drag costs one rebuild
// at the end instead of one per frame.
useEffect(() => {
const map = mapRef.current;
if (!map || !ready) return;
const refresh = () => {
if (!map.getLayer(POINT_LAYER)) return;
const seen = new Set<string>();
const picked: { id: string; at: [number, number] }[] = [];
for (const feature of map.queryRenderedFeatures({ layers: [POINT_LAYER] })) {
const id = feature.properties?.id;
if (typeof id !== 'string' || seen.has(id) || feature.geometry.type !== 'Point') continue;
const [lon, lat] = feature.geometry.coordinates;
if (typeof lon !== 'number' || typeof lat !== 'number') continue;
seen.add(id);
picked.push({ id, at: [lon, lat] });
if (picked.length >= THUMB_LIMIT) break;
}
// Rebuilding identical markers would restart every image fade for no reason.
const signature = picked.map((item) => item.id).join(',');
if (signature === shownRef.current) return;
shownRef.current = signature;
thumbsRef.current.forEach((marker) => marker.remove());
thumbsRef.current = picked.map(({ id, at }) =>
new Marker({ element: makeThumbElement(id, token, open) }).setLngLat(at).addTo(map),
);
setVisibleIds(picked.map((item) => item.id));
};
map.on('idle', refresh);
return () => {
map.off('idle', refresh);
};
}, [ready, token, open]);
const toggle = (key: BoolFilterKey) =>
setFilter((current) => ({ ...current, [key]: current[key] ? undefined : true }));
const count = markers?.length ?? 0;
return (
<div className="flex h-full flex-col">
<header className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">Map</h1>
{/* A failed marker fetch used to look identical to an empty library: the map simply stayed on its
opening view. Say so instead — the basemap can be perfectly fine while the pins are missing. */}
<span className={`text-xs ${markersError ? 'text-destructive' : 'text-muted-foreground'}`}>
{markersError
? `Markers failed: ${markersError instanceof Error ? markersError.message : 'request failed'}`
: isLoading
? 'Loading…'
: `${count.toLocaleString()} geotagged`}
</span>
<div className="flex-1" />
{TOGGLES.map(({ key, label, title }) => (
<Button
key={key}
size="sm"
variant={filter[key] ? 'default' : 'ghost'}
title={title}
onClick={() => toggle(key)}
>
{label}
</Button>
))}
</header>
<div className="relative min-h-0 flex-1">
{/*
Sized with h-full/w-full, NOT `absolute inset-0`, and that is not a style preference.
maplibre puts `position: relative; overflow: hidden` on whatever element it is handed, via its own
stylesheet's `.maplibregl-map`. Tailwind 4 emits its utilities inside `@layer utilities`, and an
UNLAYERED rule beats a layered one no matter which comes later in the file — so `.maplibregl-map`
wins over `.absolute`, `inset-0` stops applying, and the div collapses to height 0. It then clips
its own canvas away, which looks exactly like a map that failed to load: the GL context is healthy,
tiles download, pixels are drawn, and none of it is ever composited. Nothing logs a thing.
h-full survives because maplibre's stylesheet sets no height, so there is no conflict to lose.
*/}
<div ref={containerRef} className="h-full w-full" />
{!styleUrl && !mapError && (
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
Loading map
</p>
)}
{mapError && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">The map could not load</p>
<p className="max-w-sm text-xs text-muted-foreground">
The basemap is fetched from Immich&apos;s tile server, so this needs outbound internet access.
{mapError ? ` (${mapError})` : ''}
</p>
</div>
)}
{ready && !isLoading && count === 0 && (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
<p className="rounded-full bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow">
No geotagged photos match this filter.
</p>
</div>
)}
</div>
<AssetViewer ids={visibleIds} token={token} />
</div>
);
};
@@ -0,0 +1,129 @@
import { useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { SelectionBar } from './SelectionBar';
import { TimelineGrid } from './TimelineGrid';
import { PERSON_PARAM, personThumbUrl } from './shared';
import { useBucketAssets, usePeople, usePerson, useTimeBuckets } from './usePhotosData';
import { useSelection } from './useSelection';
// Faces. The wall of people, and one person's photos behind `?person=<id>`.
//
// A person's photos come from the same bucketed timeline endpoint with `personId` set, so a face with 12,000
// photos loads the same way the main timeline does rather than as one enormous response.
export const PeopleSection = () => {
const [params] = useSearchParams();
const personId = params.get(PERSON_PARAM);
return personId ? <PersonDetailView personId={personId} /> : <PeopleListView />;
};
const PeopleListView = () => {
const { token } = useClient();
const { data, isLoading } = usePeople();
// Immich hides unnamed faces behind a toggle; the named ones are what anyone actually browses by.
const [showUnnamed, setShowUnnamed] = useState(false);
const people = useMemo(
() => (data?.people ?? []).filter((person) => showUnnamed || person.name),
[data, showUnnamed],
);
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">People</h1>
<span className="text-xs text-muted-foreground">{people.length}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={() => setShowUnnamed((value) => !value)}>
{showUnnamed ? 'Hide unnamed' : 'Show unnamed'}
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && people.length === 0 && (
<p className="text-sm text-muted-foreground">
No people yet Immich populates this as faces are recognised.
</p>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-4">
{people.map((person) => (
<Link key={person.id} to={`?${PERSON_PARAM}=${person.id}`} className="group text-center">
<img
src={personThumbUrl(person.id, token)}
alt=""
loading="lazy"
className="mx-auto aspect-square w-full rounded-full object-cover ring-1 ring-white/10 transition group-hover:ring-primary"
/>
<span className="mt-2 block truncate text-sm">{person.name || 'Unnamed'}</span>
</Link>
))}
</div>
</div>
</div>
);
};
const PersonDetailView = ({ personId }: { personId: string }) => {
const { token } = useClient();
const [params] = useSearchParams();
const scrollRef = useRef<HTMLDivElement | null>(null);
const [loaded, setLoaded] = useState<string[]>([]);
const { data: person } = usePerson(personId);
const filter = useMemo(() => ({ personId }), [personId]);
const { data: buckets } = useTimeBuckets(filter);
const assetsByBucket = useBucketAssets(filter, loaded);
const orderedIds = useMemo(
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
[buckets, assetsByBucket],
);
const selection = useSelection(orderedIds);
const back = (() => {
const next = new URLSearchParams(params);
next.delete(PERSON_PARAM);
const search = next.toString();
return search ? `?${search}` : '?';
})();
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
return (
<div className="flex h-full flex-col">
{selection.isSelecting ? (
<SelectionBar selection={selection} />
) : (
<header className="flex items-center gap-2 border-b px-3 py-2">
<Button asChild variant="ghost" size="icon" title="Back to people">
<Link to={back}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<img src={personThumbUrl(personId, token)} alt="" className="h-7 w-7 rounded-full object-cover" />
<h1 className="truncate text-sm font-semibold">{person?.name || 'Unnamed'}</h1>
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
</header>
)}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
<TimelineGrid
buckets={buckets ?? []}
assetsByBucket={assetsByBucket}
onBucketVisible={(bucket) => setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]))}
token={token}
selection={selection}
scrollRef={scrollRef}
/>
</div>
<AssetViewer ids={orderedIds} token={token} />
</div>
);
};
@@ -0,0 +1,56 @@
import type { GridAsset } from './shared';
import type { Selection } from './useSelection';
import { useMemo } from 'react';
import { AssetTile } from './AssetTile';
import { justifyRows } from './shared';
import { useContainerWidth } from './useContainerWidth';
// A justified grid over a flat list of assets — albums, search results, a person's photos.
//
// The timeline uses TimelineGrid instead, because it has to lay out months it has not downloaded yet. This one
// always has every asset in hand, so it can lay the whole thing out in one pass.
type PhotoGridProps = {
assets: GridAsset[];
token: string | null;
selection: Selection;
rowHeight?: number;
gap?: number;
empty?: React.ReactNode;
};
export const PhotoGrid = ({ assets, token, selection, rowHeight = 210, gap = 4, empty }: PhotoGridProps) => {
const [ref, width] = useContainerWidth<HTMLDivElement>();
const rows = useMemo(
() =>
width > 0
? justifyRows({ items: assets, ratioOf: (a) => a.ratio, containerWidth: width, targetHeight: rowHeight, gap })
: [],
[assets, width, rowHeight, gap],
);
return (
<div ref={ref} className="w-full">
{assets.length === 0 && empty}
<div className="flex flex-col" style={{ gap }}>
{rows.map((row, index) => (
<div key={index} className="flex" style={{ gap }}>
{row.tiles.map(({ item, width: w, height: h }) => (
<AssetTile
key={item.id}
asset={item}
token={token}
width={w}
height={h}
selected={selection.selected.has(item.id)}
selecting={selection.isSelecting}
onToggle={(id, ev) => selection.toggle(id, ev)}
/>
))}
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,96 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Archive, Compass, Heart, Images, Map as MapIcon, Search, Share2, Trash2, Users } from 'lucide-react';
import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared';
import { useAssetStats, usePhotosHealth } from './usePhotosData';
// Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing
// and cleanup ones below a divider, and the library size at the bottom.
//
// Sections are react-router NavLinks to /photos/<section> — active state from the router, cmd-click works.
const ICONS: Record<PhotosSectionId, LucideIcon> = {
timeline: Images,
explore: Compass,
map: MapIcon,
search: Search,
albums: Images,
people: Users,
favorites: Heart,
archive: Archive,
sharing: Share2,
trash: Trash2,
};
// Immich draws the same line: browsing the library, then everything else.
const LIBRARY: PhotosSectionId[] = ['timeline', 'explore', 'map', 'search', 'albums', 'people', 'favorites'];
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
export const PhotosNav = () => {
const { data: stats } = useAssetStats();
const { data: health } = usePhotosHealth();
const group = (ids: PhotosSectionId[]) =>
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
const Icon = ICONS[id];
return (
<NavLink
key={id}
to={photosSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
)}
<Icon
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</>
)}
</NavLink>
);
});
const rest = PHOTOS_SECTIONS.map((section) => section.id).filter((id) => !LIBRARY.includes(id));
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-400 ring-1 ring-black/5">
<Images className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
<div className="truncate text-xs text-muted-foreground">
{health?.ok ? (health.version ?? 'connected') : 'not connected'}
</div>
</div>
</div>
<nav className="flex flex-col gap-0.5 px-2">{group(LIBRARY)}</nav>
<div className="mx-4 my-2 border-t" />
<nav className="flex flex-col gap-0.5 px-2 pb-3">{group(rest)}</nav>
<div className="flex-1" />
{stats && (
<div className="border-t px-4 py-3 text-xs text-muted-foreground">
<p>{stats.total.toLocaleString()} items</p>
<p>
{stats.images.toLocaleString()} photos · {stats.videos.toLocaleString()} videos
</p>
</div>
)}
</div>
);
};
@@ -0,0 +1,51 @@
import { AlbumsSection } from './AlbumsSection';
import { ExploreSection } from './ExploreSection';
import { MapSection } from './MapSection';
import { PeopleSection } from './PeopleSection';
import { SearchSection } from './SearchSection';
import { SharingSection } from './SharingSection';
import { TimelineSection } from './TimelineSection';
import { usePhotosHealth } from './usePhotosData';
import { usePhotosSection } from './usePhotosSection';
// Right panel of the /photos workspace — renders the section named by the URL.
//
// Timeline, favorites, archive and trash are one component with a filter; the rest are their own.
export const PhotosView = () => {
const section = usePhotosSection();
const { data: health, isLoading } = usePhotosHealth();
// Every section is useless without the upstream, and a wall of empty grids is a worse answer than saying so.
if (!isLoading && health && !health.ok) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<p className="text-sm font-medium">Photos is not connected</p>
<p className="max-w-sm text-xs text-muted-foreground">
{health.error ?? 'The photos sidecar could not reach Immich. Check IMMICH_URL and IMMICH_API_KEY.'}
</p>
</div>
);
}
switch (section) {
case 'explore':
return <ExploreSection />;
case 'map':
return <MapSection />;
case 'search':
return <SearchSection />;
case 'albums':
return <AlbumsSection />;
case 'people':
return <PeopleSection />;
case 'sharing':
return <SharingSection />;
case 'favorites':
case 'archive':
case 'trash':
case 'timeline':
default:
return <TimelineSection section={section} />;
}
};
@@ -0,0 +1,17 @@
import { Images } from 'lucide-react';
import { PHOTOS_SECTIONS } from './shared';
import { usePhotosSection } from './usePhotosSection';
// Panel header for the right (photos-view) panel.
export const PhotosViewHeader = () => {
const section = usePhotosSection();
const label = PHOTOS_SECTIONS.find((s) => s.id === section)?.label ?? 'Photos';
return (
<>
<Images className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">{label}</span>
</>
);
};
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Search, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { PhotoGrid } from './PhotoGrid';
import { SelectionBar } from './SelectionBar';
import { ALBUM_PARAM, QUERY_PARAM, photosSectionPath, thumbUrl, toGridAsset } from './shared';
import { useSearch } from './usePhotosData';
import { useSelection } from './useSelection';
// Search. The query lives in `?q=`, so a search is a link — the same reason the lightbox lives in `?asset=`.
//
// Smart search is Immich's CLIP model ("red bicycle", "birthday cake") and is the default because it is the
// thing Immich has that a file browser does not. The toggle falls back to filename/metadata matching.
export const SearchSection = () => {
const { token } = useClient();
const [params, setParams] = useSearchParams();
const submitted = params.get(QUERY_PARAM) ?? '';
const [draft, setDraft] = useState(submitted);
const [smart, setSmart] = useState(true);
// The box follows the URL, so a link into /photos/search?q=… arrives with its own query already typed.
useEffect(() => setDraft(submitted), [submitted]);
const { data, isFetching } = useSearch({ query: submitted, smart, enabled: submitted.length > 0 });
const assets = useMemo(() => (data?.assets.items ?? []).map(toGridAsset), [data]);
const selection = useSelection(assets.map((asset) => asset.id));
const submit = (ev: React.FormEvent) => {
ev.preventDefault();
setParams((current) => {
const next = new URLSearchParams(current);
if (draft.trim()) next.set(QUERY_PARAM, draft.trim());
else next.delete(QUERY_PARAM);
return next;
});
};
return (
<div className="flex h-full flex-col">
{selection.isSelecting ? (
<SelectionBar selection={selection} />
) : (
<header className="flex items-center gap-2 border-b px-3 py-2">
<form onSubmit={submit} className="flex flex-1 items-center gap-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<Input
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
placeholder={smart ? 'Describe a photo — "sunset over water"' : 'Filename or path'}
className="h-8"
/>
<Button type="submit" size="sm" variant="secondary">
Search
</Button>
</form>
<Button
variant={smart ? 'default' : 'ghost'}
size="sm"
title="Smart search uses Immich's CLIP model instead of matching filenames"
onClick={() => setSmart((value) => !value)}
>
<Sparkles className="mr-1.5 h-4 w-4" />
Smart
</Button>
</header>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{!submitted && <p className="p-2 text-sm text-muted-foreground">Search your library.</p>}
{submitted && isFetching && <p className="p-2 text-sm text-muted-foreground">Searching</p>}
{data && data.albums.items.length > 0 && (
<section className="mb-4">
<h2 className="mb-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Albums</h2>
<div className="flex flex-wrap gap-3">
{data.albums.items.map((album) => (
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`} className="w-32">
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
{album.albumThumbnailAssetId && (
<img
src={thumbUrl(album.albumThumbnailAssetId, token)}
alt=""
className="h-full w-full object-cover"
/>
)}
</div>
<p className="mt-1 truncate text-xs">{album.albumName}</p>
</Link>
))}
</div>
</section>
)}
{submitted && !isFetching && (
<PhotoGrid
assets={assets}
token={token}
selection={selection}
empty={<p className="p-2 text-sm text-muted-foreground">No matches.</p>}
/>
)}
</div>
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
</div>
);
};
@@ -0,0 +1,121 @@
import type { Selection } from './useSelection';
import { Archive, ArchiveRestore, Heart, Plus, Trash2, Undo2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAlbums } from './usePhotosData';
import { useAssetActions } from './useAssetActions';
// The bar that replaces the header once anything is selected — Immich does the same, and it keeps the
// destructive buttons off screen entirely until they have something to act on.
type SelectionBarProps = {
selection: Selection;
/** Trash swaps the whole action set: restore and delete-forever, not favorite and archive. */
inTrash?: boolean;
/** When viewing one album, offer "remove from album" alongside the library-wide actions. */
albumId?: string;
/** Currently in the archive view, so the archive button should read "unarchive". */
archived?: boolean;
};
export const SelectionBar = ({ selection, inTrash = false, albumId, archived = false }: SelectionBarProps) => {
const { ids, count, clear, selectAll } = selection;
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
useAssetActions();
const { data: albums } = useAlbums();
const run = (action: () => void) => {
action();
clear();
};
return (
<div className="flex items-center gap-1 border-b px-2 py-1.5">
<Button variant="ghost" size="icon" onClick={clear} title="Clear selection">
<X className="h-4 w-4" />
</Button>
<span className="mr-2 text-sm font-medium">{count} selected</span>
<Button variant="ghost" size="sm" onClick={selectAll}>
Select all
</Button>
<div className="flex-1" />
{inTrash ? (
<>
<Button variant="ghost" size="sm" onClick={() => run(() => restore.mutate(ids))}>
<Undo2 className="mr-1.5 h-4 w-4" />
Restore
</Button>
<Button variant="ghost" size="sm" onClick={() => run(() => deleteForever.mutate(ids))}>
<Trash2 className="mr-1.5 h-4 w-4" />
Delete permanently
</Button>
</>
) : (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<Plus className="mr-1.5 h-4 w-4" />
Add to album
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
<DropdownMenuItem
onClick={() => {
const albumName = window.prompt('New album name');
if (albumName) run(() => createAlbum.mutate({ albumName, assetIds: ids }));
}}
>
<Plus className="mr-2 h-4 w-4" />
New album
</DropdownMenuItem>
{albums && albums.length > 0 && <DropdownMenuSeparator />}
{albums?.map((album) => (
<DropdownMenuItem
key={album.id}
onClick={() => run(() => addToAlbum.mutate({ albumId: album.id, ids }))}
>
{album.albumName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{albumId && (
<Button variant="ghost" size="sm" onClick={() => run(() => removeFromAlbum.mutate({ albumId, ids }))}>
Remove from album
</Button>
)}
<Button
variant="ghost"
size="icon"
title="Favorite"
onClick={() => run(() => setFavorite.mutate({ ids, isFavorite: true }))}
>
<Heart className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={archived ? 'Unarchive' : 'Archive'}
onClick={() => run(() => setArchived.mutate({ ids, archived: !archived }))}
>
{archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" title="Move to trash" onClick={() => run(() => trash.mutate(ids))}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
);
};
@@ -0,0 +1,100 @@
import { Link } from 'react-router';
import { Copy, ImageOff, Link2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
import { useAlbums, useSharedLinks } from './usePhotosData';
// Sharing: shared albums and shared links, read-mostly.
//
// The link URL is Immich's own public URL, not an Officer route — a shared link is meant to be opened by
// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
export const SharingSection = () => {
const { token } = useClient();
const { data: links, isLoading } = useSharedLinks();
const { data: albums } = useAlbums();
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">Sharing</h1>
</header>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
<section>
<h2 className="mb-2 text-sm font-semibold">Shared albums</h2>
{sharedAlbums.length === 0 ? (
<p className="text-sm text-muted-foreground">No shared albums.</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{sharedAlbums.map((album) => (
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`}>
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
{album.albumThumbnailAssetId ? (
<img
src={thumbUrl(album.albumThumbnailAssetId, token)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<ImageOff className="h-8 w-8" />
</div>
)}
</div>
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
<p className="truncate text-xs text-muted-foreground">
{album.assetCount} items · {album.albumUsers.length} people
</p>
</Link>
))}
</div>
)}
</section>
<section>
<h2 className="mb-2 text-sm font-semibold">Shared links</h2>
{isLoading && <p className="text-sm text-muted-foreground">Loading</p>}
{!isLoading && (links ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">No shared links.</p>
)}
<ul className="divide-y rounded-lg border">
{(links ?? []).map((link) => (
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
</p>
<p className="text-xs text-muted-foreground">
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
{link.allowDownload ? ' · download' : ''}
{link.allowUpload ? ' · upload' : ''}
</p>
</div>
<Button
variant="ghost"
size="icon"
title="Copy share key — append it to your instance's /share/ URL"
onClick={() => {
void navigator.clipboard.writeText(link.key);
toast.success('Share key copied');
}}
>
<Copy className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</section>
</div>
</div>
);
};
@@ -0,0 +1,145 @@
import type { GridAsset, TimeBucket } from './shared';
import type { Selection } from './useSelection';
import { useEffect, useMemo, useRef } from 'react';
import { AssetTile } from './AssetTile';
import { estimateBucketHeight, formatBucket, justifyRows } from './shared';
import { useContainerWidth } from './useContainerWidth';
// The month-bucketed timeline.
//
// Every month in the library is rendered from the moment the screen opens, but only as a correctly-sized empty
// box: the bucket index gives a count, and `estimateBucketHeight` turns a count into a height. That is what
// makes the scrollbar honest before anything is downloaded — the page is already its true length, so dragging
// to the bottom lands on the oldest month instead of on a spinner that then pushes the content away.
//
// An IntersectionObserver with a tall rootMargin turns a box into a real request just before it reaches the
// viewport. Loaded months are never unloaded; React Query holds them and scrolling back up is instant.
const ROW_HEIGHT = 210;
const GAP = 4;
type TimelineGridProps = {
buckets: TimeBucket[];
assetsByBucket: Record<string, GridAsset[]>;
onBucketVisible: (bucket: string) => void;
token: string | null;
selection: Selection;
/** The scroll container the observer should watch. Panels scroll their own body, not the window. */
scrollRef: React.RefObject<HTMLElement | null>;
};
export const TimelineGrid = ({
buckets,
assetsByBucket,
onBucketVisible,
token,
selection,
scrollRef,
}: TimelineGridProps) => {
const [ref, width] = useContainerWidth<HTMLDivElement>();
const nodes = useRef(new Map<string, HTMLElement>());
const notify = useRef(onBucketVisible);
notify.current = onBucketVisible;
useEffect(() => {
if (!width) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const bucket = entry.target.getAttribute('data-bucket');
if (entry.isIntersecting && bucket) notify.current(bucket);
}
},
// Two viewports of lead time in each direction: enough that a fast scroll rarely outruns the fetch,
// small enough that opening the screen doesn't request the entire library.
{ root: scrollRef.current ?? null, rootMargin: '200% 0px' },
);
for (const node of nodes.current.values()) observer.observe(node);
return () => observer.disconnect();
}, [width, buckets, scrollRef]);
return (
<div ref={ref} className="w-full">
{buckets.map((bucket) => (
<BucketSection
key={bucket.timeBucket}
bucket={bucket}
assets={assetsByBucket[bucket.timeBucket]}
width={width}
token={token}
selection={selection}
register={(node) => {
if (node) nodes.current.set(bucket.timeBucket, node);
else nodes.current.delete(bucket.timeBucket);
}}
/>
))}
</div>
);
};
type BucketSectionProps = {
bucket: TimeBucket;
assets: GridAsset[] | undefined;
width: number;
token: string | null;
selection: Selection;
register: (node: HTMLElement | null) => void;
};
const BucketSection = ({ bucket, assets, width, token, selection, register }: BucketSectionProps) => {
const rows = useMemo(
() =>
assets && width > 0
? justifyRows({
items: assets,
ratioOf: (a) => a.ratio,
containerWidth: width,
targetHeight: ROW_HEIGHT,
gap: GAP,
})
: [],
[assets, width],
);
// Until the month arrives, hold its place with the height it will occupy. The estimate uses the same row
// height and gap the real layout will, so the jump when it loads is a few pixels rather than a page.
const placeholder =
!assets && width > 0
? estimateBucketHeight({ count: bucket.count, containerWidth: width, rowHeight: ROW_HEIGHT, gap: GAP })
: 0;
return (
<section ref={register} data-bucket={bucket.timeBucket} className="mb-6">
<h2 className="sticky top-0 z-10 -mx-1 mb-2 bg-background/85 px-1 py-1.5 text-sm font-semibold backdrop-blur">
{formatBucket(bucket.timeBucket)}
<span className="ml-2 text-xs font-normal text-muted-foreground">{bucket.count}</span>
</h2>
{assets ? (
<div className="flex flex-col" style={{ gap: GAP }}>
{rows.map((row, index) => (
<div key={index} className="flex" style={{ gap: GAP }}>
{row.tiles.map(({ item, width: w, height: h }) => (
<AssetTile
key={item.id}
asset={item}
token={token}
width={w}
height={h}
selected={selection.selected.has(item.id)}
selecting={selection.isSelecting}
onToggle={(id, ev) => selection.toggle(id, ev)}
/>
))}
</div>
))}
</div>
) : (
<div style={{ height: placeholder }} className="w-full rounded-sm bg-white/[0.03]" />
)}
</section>
);
};
@@ -0,0 +1,95 @@
import type { PhotosSectionId } from './shared';
import type { TimelineFilter } from './usePhotosData';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { AssetViewer } from './AssetViewer';
import { SelectionBar } from './SelectionBar';
import { TimelineGrid } from './TimelineGrid';
import { useAssetActions } from './useAssetActions';
import { useBucketAssets, useTimeBuckets } from './usePhotosData';
import { useSelection } from './useSelection';
// Timeline, Favorites, Archive and Trash are the same screen with a different filter — which is also true in
// Immich, and is why the bucket API takes the filter as query params rather than having four endpoints.
const FILTERS: Partial<Record<PhotosSectionId, TimelineFilter>> = {
timeline: { visibility: 'timeline', withPartners: true, withStacked: true },
favorites: { isFavorite: true, withStacked: true },
archive: { visibility: 'archive', withStacked: true },
trash: { isTrashed: true },
};
const TITLES: Partial<Record<PhotosSectionId, string>> = {
timeline: 'Photos',
favorites: 'Favorites',
archive: 'Archive',
trash: 'Trash',
};
type TimelineSectionProps = { section: PhotosSectionId };
export const TimelineSection = ({ section }: TimelineSectionProps) => {
const { token } = useClient();
const scrollRef = useRef<HTMLDivElement | null>(null);
const [loaded, setLoaded] = useState<string[]>([]);
const { emptyTrash } = useAssetActions();
const filter = FILTERS[section] ?? FILTERS.timeline!;
const inTrash = section === 'trash';
const { data: buckets, isLoading } = useTimeBuckets(filter);
const assetsByBucket = useBucketAssets(filter, loaded);
const orderedIds = useMemo(
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
[buckets, assetsByBucket],
);
const selection = useSelection(orderedIds);
const onBucketVisible = useCallback((bucket: string) => {
setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]));
}, []);
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
return (
<div className="flex h-full flex-col">
{selection.isSelecting ? (
<SelectionBar selection={selection} inTrash={inTrash} archived={section === 'archive'} />
) : (
<header className="flex items-center gap-2 border-b px-3 py-2">
<h1 className="text-sm font-semibold">{TITLES[section] ?? 'Photos'}</h1>
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
<div className="flex-1" />
{inTrash && total > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => window.confirm(`Permanently delete ${total} items?`) && emptyTrash.mutate()}
>
<Trash2 className="mr-1.5 h-4 w-4" />
Empty trash
</Button>
)}
</header>
)}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
{isLoading && <p className="p-4 text-sm text-muted-foreground">Loading</p>}
{!isLoading && total === 0 && <p className="p-4 text-sm text-muted-foreground">Nothing here.</p>}
<TimelineGrid
buckets={buckets ?? []}
assetsByBucket={assetsByBucket}
onBucketVisible={onBucketVisible}
token={token}
selection={selection}
scrollRef={scrollRef}
/>
</div>
<AssetViewer ids={orderedIds} token={token} inTrash={inTrash} />
</div>
);
};
@@ -0,0 +1,19 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { PanelLeft, LayoutGrid } from 'lucide-react';
import { PhotosNav } from './PhotosNav';
import { PhotosView } from './PhotosView';
import { PhotosViewHeader } from './PhotosViewHeader';
export { PhotosNav, PhotosView };
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'photos-nav', name: 'Photos', icon: PanelLeft, component: PhotosNav, availableOnPanel: false },
{
key: 'photos-view',
name: 'Photos',
icon: LayoutGrid,
component: PhotosView,
header: PhotosViewHeader,
availableOnPanel: false,
},
];
@@ -0,0 +1,399 @@
// Shared types, constants and pure helpers for the /photos workspace panels.
//
// Unlike the Headscale panels — whose sidecar reshapes Headscale's API into Officer's own types — the photos
// sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
// generated from the LIVE instance's OpenAPI document (`GET /api/spec.json`, Immich 3.0.3), not from memory:
// several of them changed shape in 3.0 (`isArchived` became `visibility`, and the timeline bucket response
// became columnar), so anything written from an older mental model would be subtly wrong.
export const PHOTOS_SECTIONS = [
{ id: 'timeline', label: 'Photos' },
{ id: 'explore', label: 'Explore' },
{ id: 'map', label: 'Map' },
{ id: 'search', label: 'Search' },
{ id: 'albums', label: 'Albums' },
{ id: 'people', label: 'People' },
{ id: 'favorites', label: 'Favorites' },
{ id: 'archive', label: 'Archive' },
{ id: 'sharing', label: 'Sharing' },
{ id: 'trash', label: 'Trash' },
] as const;
export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];
/** Where /photos lands, and where an unrecognised section redirects to. */
export const DEFAULT_PHOTOS_SECTION: PhotosSectionId = 'timeline';
export const isPhotosSection = (value: string | undefined): value is PhotosSectionId =>
PHOTOS_SECTIONS.some((s) => s.id === value);
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
export const photosSectionPath = (id: PhotosSectionId) => `/photos/${id}`;
/** Which asset is open in the lightbox — `?asset=<id>`. */
export const ASSET_PARAM = 'asset';
/** Which album is open — `?album=<id>`. */
export const ALBUM_PARAM = 'album';
/** Which person is open — `?person=<id>`. */
export const PERSON_PARAM = 'person';
/** The search text — `?q=…`. */
export const QUERY_PARAM = 'q';
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
export type AssetVisibility = 'archive' | 'timeline' | 'hidden' | 'locked';
export type AssetType = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER';
export type ExifInfo = {
make?: string | null;
model?: string | null;
lensModel?: string | null;
exifImageWidth?: number | null;
exifImageHeight?: number | null;
fileSizeInByte?: number | null;
dateTimeOriginal?: string | null;
fNumber?: number | null;
focalLength?: number | null;
iso?: number | null;
exposureTime?: string | null;
latitude?: number | null;
longitude?: number | null;
city?: string | null;
state?: string | null;
country?: string | null;
description?: string | null;
rating?: number | null;
};
export type Asset = {
id: string;
type: AssetType;
thumbhash: string | null;
localDateTime: string;
fileCreatedAt: string;
/** Milliseconds, null for stills. It was an `HH:MM:SS.mmm` string before Immich 3.0 — it is a number now. */
duration: number | null;
livePhotoVideoId?: string | null;
width: number;
height: number;
ownerId: string;
originalFileName: string;
originalPath: string;
originalMimeType?: string;
isFavorite: boolean;
isArchived: boolean;
isTrashed: boolean;
isOffline: boolean;
visibility: AssetVisibility;
exifInfo?: ExifInfo;
people?: Person[];
stack?: { id: string; primaryAssetId: string; assetCount: number } | null;
};
export type Album = {
id: string;
albumName: string;
description: string;
createdAt: string;
updatedAt: string;
albumThumbnailAssetId: string | null;
shared: boolean;
hasSharedLink: boolean;
assetCount: number;
startDate?: string;
endDate?: string;
albumUsers: { user: { id: string; name: string; email: string } }[];
};
/** `GET /albums/{id}` returns the album with its assets attached. */
export type AlbumDetail = Album & { assets?: Asset[] };
export type Person = {
id: string;
name: string;
birthDate: string | null;
thumbnailPath: string;
isHidden: boolean;
isFavorite?: boolean;
};
export type PeopleResponse = { total: number; hidden: number; people: Person[]; hasNextPage?: boolean };
export type SearchResponse = {
assets: { total: number; count: number; items: Asset[]; nextPage: string | null };
albums: { total: number; count: number; items: Album[] };
};
export type ExploreItem = { value: string; data: Asset };
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
export type SharedLink = {
id: string;
key: string;
type: string;
album?: Album | null;
assets: Asset[];
description?: string | null;
expiresAt: string | null;
createdAt: string;
allowDownload: boolean;
allowUpload: boolean;
};
export type TimeBucket = { timeBucket: string; count: number };
/** `GET /map/markers` — one row per geotagged asset. Deliberately tiny: the whole library fits in one response. */
export type MapMarker = { id: string; lat: number; lon: number; city: string | null; country: string | null };
/**
* The slice of `GET /server/config` this UI needs.
*
* The map style is Immich's own, fetched by the browser straight from `tiles.immich.cloud` — the same place
* Immich's web app gets it, and the only part of /photos that talks to anything other than Officer. It is not
* proxied because a vector basemap is thousands of tile requests that have nothing to do with the library.
*/
export type ServerConfig = { mapDarkStyleUrl: string; mapLightStyleUrl: string; version?: string };
/**
* `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
* because a month of assets is tens of thousands of near-identical objects and the array-of-arrays form is a
* fraction of the JSON. Zip it with `bucketAssets()` before touching it.
*/
export type TimeBucketColumns = {
id: string[];
ownerId: string[];
ratio: number[];
isFavorite: boolean[];
visibility: AssetVisibility[];
isTrashed: boolean[];
isImage: boolean[];
thumbhash: (string | null)[];
createdAt: string[];
fileCreatedAt: string[];
localOffsetHours: number[];
/** Milliseconds, null for stills — same units as `Asset.duration`. */
duration: (number | null)[];
stack?: (string[] | null)[];
projectionType: (string | null)[];
livePhotoVideoId: (string | null)[];
city?: (string | null)[];
country?: (string | null)[];
};
/** The minimum a tile needs to draw itself. Both the columnar timeline and a full Asset reduce to this. */
export type GridAsset = {
id: string;
/** width / height. The whole point of the columnar payload: layout without loading a single image. */
ratio: number;
isFavorite: boolean;
isImage: boolean;
isTrashed: boolean;
/** Milliseconds, null for stills. */
duration: number | null;
livePhotoVideoId: string | null;
fileCreatedAt: string;
stackCount: number;
};
export function bucketAssets(cols: TimeBucketColumns): GridAsset[] {
return cols.id.map((id, i) => ({
id,
ratio: cols.ratio[i] || 1,
isFavorite: cols.isFavorite[i] ?? false,
isImage: cols.isImage[i] ?? true,
isTrashed: cols.isTrashed[i] ?? false,
duration: cols.duration?.[i] ?? null,
livePhotoVideoId: cols.livePhotoVideoId?.[i] ?? null,
fileCreatedAt: cols.fileCreatedAt[i] ?? '',
// stack is [stackId, assetCount] when present.
stackCount: Number(cols.stack?.[i]?.[1] ?? 0),
}));
}
export const toGridAsset = (a: Asset): GridAsset => ({
id: a.id,
ratio: a.height ? a.width / a.height : 1,
isFavorite: a.isFavorite,
isImage: a.type === 'IMAGE',
isTrashed: a.isTrashed,
duration: a.duration ?? null,
livePhotoVideoId: a.livePhotoVideoId ?? null,
fileCreatedAt: a.fileCreatedAt,
stackCount: a.stack?.assetCount ?? 0,
});
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
//
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape
// hatch userMiddleware already supports for <audio>/<video> and the one the music app uses for cover art.
// The photos sidecar strips the token before forwarding, so it never reaches Immich or its access log.
const withToken = (url: string, token: string | null) =>
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
export const PHOTOS_API = '/api/photos/_officer';
/** `thumbnail` is the grid tile (~250px); `preview` is the lightbox; `fullsize` is the decoded original. */
export const thumbUrl = (id: string, token: string | null, size: 'thumbnail' | 'preview' | 'fullsize' = 'thumbnail') =>
withToken(`${PHOTOS_API}/assets/${id}/thumbnail?size=${size}`, token);
export const originalUrl = (id: string, token: string | null) =>
withToken(`${PHOTOS_API}/assets/${id}/original`, token);
export const videoUrl = (id: string, token: string | null) =>
withToken(`${PHOTOS_API}/assets/${id}/video/playback`, token);
export const personThumbUrl = (id: string, token: string | null) =>
withToken(`${PHOTOS_API}/people/${id}/thumbnail`, token);
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
export const formatBytes = (bytes: number | null | undefined): string => {
if (!bytes) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
};
/**
* Milliseconds → the grid badge's `M:SS` (or `H:MM:SS` when it's long).
*
* Immich 3.0 sends durations as an integer count of milliseconds. Older versions sent an `HH:MM:SS.mmm`
* string, which is worth knowing because every stale example on the internet still shows that form — and
* `'…'.split(':')` on a number is a render-time TypeError, not a wrong label.
*/
export const formatDuration = (ms: number | null | undefined): string | null => {
if (!ms || !Number.isFinite(ms)) return null;
const total = Math.floor(ms / 1000);
if (total <= 0) return null;
const pad = (n: number) => String(n).padStart(2, '0');
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
};
const MONTH_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
const LONG_DATE = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
const SHORT_DATE = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
const TIME = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
/**
* `2026-08-01` → `August 2026`.
*
* Parsed by hand, not with `new Date(bucket)`: a bare `YYYY-MM-DD` is read as UTC midnight, so formatting it
* in a timezone west of UTC lands on the last day of the PREVIOUS month and the header reads July.
*/
export const formatBucket = (bucket: string): string => {
const [year, month] = bucket.split('-').map(Number);
if (!year || !month) return bucket;
return MONTH_YEAR.format(new Date(year, month - 1, 1));
};
export const formatLongDate = (iso: string | null | undefined): string => {
if (!iso) return '—';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '—' : LONG_DATE.format(date);
};
export const formatShortDate = (iso: string | null | undefined): string => {
if (!iso) return '—';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '—' : SHORT_DATE.format(date);
};
export const formatTime = (iso: string | null | undefined): string => {
if (!iso) return '';
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
};
/** `2020-01-05` + `2020-03-11` → `5 Jan 2020 11 Mar 2020`, collapsing to one when they match. */
export const formatDateRange = (start?: string, end?: string): string | null => {
if (!start && !end) return null;
const a = start ? formatShortDate(start) : null;
const b = end ? formatShortDate(end) : null;
if (a && b && a !== b) return `${a} ${b}`;
return a ?? b;
};
// ── Justified rows ────────────────────────────────────────────────────────────────────────────────
export type LaidOutTile<T> = { item: T; width: number; height: number };
export type LaidOutRow<T> = { tiles: LaidOutTile<T>[]; height: number };
type JustifyParams<T> = {
items: T[];
ratioOf: (item: T) => number;
containerWidth: number;
targetHeight: number;
gap: number;
};
/**
* Immich's signature layout: variable-width tiles packed into rows of equal height, each row filling the
* container exactly, so nothing is cropped and there are no ragged gaps.
*
* Greedy — take tiles at the target height until the row overflows, then scale the row down to fit. The last
* row is left at the target height rather than stretched, because stretching four holiday photos across a
* 2000px viewport makes them enormous.
*/
export function justifyRows<T>({
items,
ratioOf,
containerWidth,
targetHeight,
gap,
}: JustifyParams<T>): LaidOutRow<T>[] {
if (containerWidth <= 0 || items.length === 0) return [];
// A panorama can be wider than the container on its own; clamp so one asset can't own a 12-wide row. The
// SAME clamped value has to drive both the row's ratio sum and the tile's width, or the row is scaled to fit
// a total it then overflows.
const clampRatio = (item: T) => Math.min(Math.max(ratioOf(item) || 1, 0.2), 5);
const rows: LaidOutRow<T>[] = [];
let current: T[] = [];
let ratioSum = 0;
const flush = (stretch: boolean) => {
if (current.length === 0) return;
const available = containerWidth - gap * (current.length - 1);
const height = stretch ? available / ratioSum : targetHeight;
rows.push({
height,
tiles: current.map((item) => ({ item, width: Math.max(1, clampRatio(item) * height), height })),
});
current = [];
ratioSum = 0;
};
for (const item of items) {
const ratio = clampRatio(item);
current.push(item);
ratioSum += ratio;
const width = ratioSum * targetHeight + gap * (current.length - 1);
if (width >= containerWidth) flush(true);
}
flush(false);
return rows;
}
/** Estimated pixel height of an unloaded bucket, so the scrollbar is roughly honest before it loads. */
type EstimateParams = { count: number; containerWidth: number; rowHeight: number; gap: number };
export const estimateBucketHeight = ({ count, containerWidth, rowHeight, gap }: EstimateParams): number => {
const perRow = Math.max(1, Math.round(containerWidth / (rowHeight * 1.35)));
const rows = Math.ceil(count / perRow);
return rows * (rowHeight + gap);
};
@@ -0,0 +1,136 @@
import type { Album } from './shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { toast } from 'sonner';
// Every mutation the /photos panels can perform, in one place.
//
// All of them are bulk-shaped even when the UI is acting on a single tile: Immich's own API is
// `PUT /assets {ids, …}` / `DELETE /assets {ids}`, and having one code path means the selection bar and the
// lightbox's single-asset buttons cannot drift into behaving differently.
//
// Invalidation is broad — the whole ['photos'] key. Favoriting an asset changes the favorites bucket index,
// the timeline tile, the album cover and the asset detail; the queries are cheap and correctness is worth
// more than a surgical cache patch.
export function useAssetActions() {
const { put, post, patch, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['photos'] });
const fail = (verb: string) => (err: unknown) =>
toast.error(`Could not ${verb}: ${err instanceof Error ? err.message : String(err)}`);
const setFavorite = useMutation({
mutationFn: ({ ids, isFavorite }: { ids: string[]; isFavorite: boolean }) =>
put('/photos/_officer/assets', { ids, isFavorite }),
onSuccess: invalidate,
onError: fail('update favorites'),
});
const setArchived = useMutation({
mutationFn: ({ ids, archived }: { ids: string[]; archived: boolean }) =>
put('/photos/_officer/assets', { ids, visibility: archived ? 'archive' : 'timeline' }),
onSuccess: () => {
invalidate();
toast.success('Moved');
},
onError: fail('archive'),
});
// Immich's delete is a move to trash unless `force`. Keep those as two named actions rather than one with a
// boolean at the call site — "delete" and "delete permanently" should not look alike in the code either.
const trash = useMutation({
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids }),
onSuccess: (_data, ids) => {
invalidate();
toast.success(`${ids.length} moved to trash`);
},
onError: fail('delete'),
});
const deleteForever = useMutation({
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids, force: true }),
onSuccess: (_data, ids) => {
invalidate();
toast.success(`${ids.length} permanently deleted`);
},
onError: fail('delete permanently'),
});
const restore = useMutation({
mutationFn: (ids: string[]) => post('/photos/_officer/trash/restore/assets', { ids }),
onSuccess: (_data, ids) => {
invalidate();
toast.success(`${ids.length} restored`);
},
onError: fail('restore'),
});
const emptyTrash = useMutation({
mutationFn: () => post('/photos/_officer/trash/empty'),
onSuccess: () => {
invalidate();
toast.success('Trash emptied');
},
onError: fail('empty the trash'),
});
const addToAlbum = useMutation({
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
put(`/photos/_officer/albums/${albumId}/assets`, { ids }),
onSuccess: (_data, { ids }) => {
invalidate();
toast.success(`${ids.length} added to album`);
},
onError: fail('add to the album'),
});
const removeFromAlbum = useMutation({
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
del(`/photos/_officer/albums/${albumId}/assets`, { ids }),
onSuccess: () => {
invalidate();
toast.success('Removed from album');
},
onError: fail('remove from the album'),
});
const createAlbum = useMutation({
mutationFn: ({ albumName, assetIds }: { albumName: string; assetIds?: string[] }) =>
post<Album>('/photos/_officer/albums', { albumName, assetIds: assetIds ?? [] }),
onSuccess: invalidate,
onError: fail('create the album'),
});
const deleteAlbum = useMutation({
mutationFn: (albumId: string) => del(`/photos/_officer/albums/${albumId}`),
onSuccess: () => {
invalidate();
toast.success('Album deleted');
},
onError: fail('delete the album'),
});
// PATCH, not PUT: on Immich, PUT /albums/{id}/assets means "add assets" — the album itself is patched.
const renameAlbum = useMutation({
mutationFn: ({ albumId, albumName }: { albumId: string; albumName: string }) =>
patch(`/photos/_officer/albums/${albumId}`, { albumName }),
onSuccess: invalidate,
onError: fail('rename the album'),
});
return {
setFavorite,
setArchived,
trash,
deleteForever,
restore,
emptyTrash,
addToAlbum,
removeFromAlbum,
createAlbum,
deleteAlbum,
renameAlbum,
};
}
@@ -0,0 +1,28 @@
import { useLayoutEffect, useRef, useState } from 'react';
/**
* Measure an element's inner width and keep it current.
*
* The justified grid needs a real pixel width before it can lay anything out, and panels here are resized by
* dragging a splitter — a width read once on mount is wrong within seconds. ResizeObserver rather than a
* window resize listener for exactly that reason: the window never changes size, the panel does.
*/
export function useContainerWidth<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setWidth(el.clientWidth);
const observer = new ResizeObserver((entries) => {
const next = entries[0]?.contentRect.width ?? 0;
setWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next));
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
@@ -0,0 +1,247 @@
import type {
Album,
AlbumDetail,
Asset,
ExploreSection,
GridAsset,
MapMarker,
PeopleResponse,
Person,
SearchResponse,
ServerConfig,
SharedLink,
TimeBucket,
TimeBucketColumns,
} from './shared';
import { useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { bucketAssets } from './shared';
// Reads for the /photos panels. Everything goes through /api/photos/_officer/*, which is the auth proxy in
// front of the officer-photos sidecar — the browser never learns the Immich URL or its API key.
//
// The timeline is two queries, not one: a cheap bucket INDEX (one row per month) and a per-bucket fetch the
// grid triggers as you scroll. Immich's own web app works this way, and it is the only reason a library of
// 100k assets can render a correct scrollbar without downloading 100k rows first.
const KEY = 'photos';
/** Timeline filters. The same query shape serves the main timeline, favorites, archive, trash and albums. */
export type TimelineFilter = {
albumId?: string;
personId?: string;
tagId?: string;
isFavorite?: boolean;
isTrashed?: boolean;
visibility?: 'timeline' | 'archive';
withPartners?: boolean;
withStacked?: boolean;
};
type QueryValues = Record<string, string | number | boolean | undefined>;
const filterQuery = (filter: QueryValues): string => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (value !== undefined && value !== null) params.set(key, String(value));
}
return params.toString();
};
export function useTimeBuckets(filter: TimelineFilter, enabled = true) {
const { get } = useClient();
const query = filterQuery(filter);
return useQuery({
queryKey: [KEY, 'buckets', query],
queryFn: () => get<TimeBucket[]>(`/photos/_officer/timeline/buckets?${query}`),
enabled,
staleTime: 60_000,
});
}
/**
* Fetch several buckets at once — the months the grid has actually scrolled into view.
*
* `useQueries` rather than a loop of `useTimeBucket`, because the number of loaded months grows as you scroll
* and hooks cannot be called conditionally. Each bucket keeps its own cache entry, so scrolling back up is
* free and a mutation invalidating ['photos'] refetches only the months still mounted. A month that has
* already scrolled past does not refetch when you scroll back to it.
*/
export function useBucketAssets(filter: TimelineFilter, buckets: string[]) {
const { get } = useClient();
const query = filterQuery(filter);
const results = useQueries({
queries: buckets.map((bucket) => ({
queryKey: [KEY, 'bucket', query, bucket],
queryFn: () =>
get<TimeBucketColumns>(`/photos/_officer/timeline/bucket?${query}&timeBucket=${encodeURIComponent(bucket)}`),
staleTime: 5 * 60_000,
})),
});
return useMemo(() => {
const map: Record<string, GridAsset[]> = {};
buckets.forEach((bucket, i) => {
const cols = results[i]?.data;
if (cols) map[bucket] = bucketAssets(cols);
});
return map;
// `results` is a fresh array every render; its identity is meaningless. The data is what matters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [buckets, results.map((r) => r.dataUpdatedAt).join(',')]);
}
export function useAlbums() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'albums'],
queryFn: () => get<Album[]>('/photos/_officer/albums'),
staleTime: 30_000,
});
}
export function useAlbum(id: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'album', id],
queryFn: () => get<AlbumDetail>(`/photos/_officer/albums/${id}`),
enabled: !!id,
staleTime: 30_000,
});
}
export function usePeople() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'people'],
queryFn: () => get<PeopleResponse>('/photos/_officer/people?withHidden=false&size=1000'),
staleTime: 60_000,
});
}
export function usePerson(id: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'person', id],
queryFn: () => get<Person>(`/photos/_officer/people/${id}`),
enabled: !!id,
staleTime: 60_000,
});
}
export function useExplore() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'explore'],
queryFn: () => get<ExploreSection[]>('/photos/_officer/search/explore'),
staleTime: 5 * 60_000,
});
}
export function useSharedLinks() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'shared-links'],
queryFn: () => get<SharedLink[]>('/photos/_officer/shared-links'),
staleTime: 30_000,
});
}
export function useAsset(id: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'asset', id],
queryFn: () => get<Asset>(`/photos/_officer/assets/${id}`),
enabled: !!id,
staleTime: 60_000,
});
}
/**
* Search. `smart` is Immich's CLIP search ("a dog on a beach") and is what the box does by default; `metadata`
* matches filenames, paths and EXIF text. They take the same filters and return the same envelope, so the mode
* is one flag rather than two hooks.
*/
type SearchParams = { query: string; smart: boolean; enabled: boolean };
export function useSearch({ query, smart, enabled }: SearchParams) {
const { post } = useClient();
const text = query.trim();
return useQuery({
queryKey: [KEY, 'search', smart ? 'smart' : 'metadata', text],
queryFn: () =>
smart
? post<SearchResponse>('/photos/_officer/search/smart', { query: text, size: 250, withExif: true })
: post<SearchResponse>('/photos/_officer/search/metadata', {
originalFileName: text,
size: 250,
withExif: true,
order: 'desc',
}),
enabled: enabled && text.length > 0,
staleTime: 60_000,
});
}
export type AssetStats = { images: number; videos: number; total: number };
export function useAssetStats() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'stats'],
queryFn: () => get<AssetStats>('/photos/_officer/assets/statistics'),
staleTime: 5 * 60_000,
});
}
/**
* Map pins. `/map/markers` is NOT bucketed — it returns every geotagged asset in one array, because a marker is
* six numbers and the whole library is a couple of megabytes at most. Clustering happens in the browser.
*
* Note `isArchived`, not `visibility`: the map endpoint kept the old boolean flags when the timeline moved to
* the `visibility` enum, so this filter deliberately does not match `TimelineFilter`.
*/
export type MapFilter = {
isArchived?: boolean;
isFavorite?: boolean;
withPartners?: boolean;
withSharedAlbums?: boolean;
fileCreatedAfter?: string;
fileCreatedBefore?: string;
};
export function useMapMarkers(filter: MapFilter) {
const { get } = useClient();
const query = filterQuery(filter);
return useQuery({
queryKey: [KEY, 'map-markers', query],
queryFn: () => get<MapMarker[]>(`/photos/_officer/map/markers?${query}`),
staleTime: 5 * 60_000,
});
}
/** Immich's own map style URLs live in its server config, so the owner's Immich settings pick the basemap. */
export function useServerConfig() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'server-config'],
queryFn: () => get<ServerConfig>('/photos/_officer/server/config'),
staleTime: 30 * 60_000,
});
}
export type PhotosHealth = { ok: boolean; version?: string | null; user?: string | null; error?: string };
export function usePhotosHealth() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'health'],
queryFn: () => get<PhotosHealth>('/photos/_health'),
staleTime: 60_000,
retry: false,
});
}
@@ -0,0 +1,10 @@
import { useParams } from 'react-router';
import { DEFAULT_PHOTOS_SECTION, isPhotosSection, type PhotosSectionId } from './shared';
// The URL names the section; nothing else does. PhotosScreen redirects anything unrecognised, so the fallback
// here only covers the instant before that lands.
export function usePhotosSection(): PhotosSectionId {
const { section } = useParams();
return isPhotosSection(section) ? section : DEFAULT_PHOTOS_SECTION;
}
@@ -0,0 +1,65 @@
import { useCallback, useMemo, useRef, useState } from 'react';
/**
* Multi-select over an ordered list of asset ids.
*
* Shift-click selects a range, which is why the hook needs the ORDER of everything currently on screen and not
* just the set of chosen ids. The order comes from the view, so a timeline that has loaded three months and an
* album that has loaded one page both get the same behaviour without either knowing how the other paginates.
*/
export function useSelection(orderedIds: string[]) {
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const anchor = useRef<string | null>(null);
const order = useMemo(() => new Map(orderedIds.map((id, i) => [id, i])), [orderedIds]);
const toggle = useCallback(
(id: string, ev?: { shiftKey?: boolean; preventDefault?: () => void; stopPropagation?: () => void }) => {
ev?.preventDefault?.();
ev?.stopPropagation?.();
setSelected((prev) => {
const next = new Set(prev);
const from = anchor.current;
if (ev?.shiftKey && from && order.has(from) && order.has(id)) {
const a = order.get(from) ?? 0;
const b = order.get(id) ?? 0;
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) {
const between = orderedIds[i];
if (between) next.add(between);
}
return next;
}
if (next.has(id)) next.delete(id);
else next.add(id);
anchor.current = id;
return next;
});
},
[order, orderedIds],
);
const clear = useCallback(() => {
anchor.current = null;
setSelected(new Set());
}, []);
const selectAll = useCallback(() => setSelected(new Set(orderedIds)), [orderedIds]);
const ids = useMemo(() => orderedIds.filter((id) => selected.has(id)), [orderedIds, selected]);
return {
selected,
/** In document order, so "add to album" preserves what the eye saw rather than insertion order. */
ids,
count: selected.size,
isSelecting: selected.size > 0,
toggle,
clear,
selectAll,
};
}
export type Selection = ReturnType<typeof useSelection>;
+4
View File
@@ -31,6 +31,10 @@ export { CodeEditorView } from './apps/CodeEditor';
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
export type { HeadscaleSectionId } from './apps/Headscale/shared';
// Same for /photos.
export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared';
export type { PhotosSectionId } from './apps/Photos/shared';
// Same for /transmission.
export {
DEFAULT_TRANSMISSION_SECTION,