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:
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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>;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user