The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:
• No reverse dependency. Nothing in src/ imported anything of photos'.
• Configuration was already right. The URL and API key live in photos_config,
a per-user registry encrypted at rest and driven from the app. Vault had to
move VAULTWARDEN_URL out of .env and invent a settings panel; here the
sidecar already read the database, and its own comment already said why
nothing reads process.env.
• The frontend was already panels. The screen was a WorkspaceView around
exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
the screen's contents with the wrapper removed.
The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.
Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.
Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
// Shared types, constants and pure helpers for the /photos workspace panels.
|
||
//
|
||
// Unlike a plugin whose sidecar reshapes an upstream API into Officer's own types — offscale does — 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' },
|
||
{ id: 'settings', label: 'Connection' },
|
||
] 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);
|
||
};
|