Files
photos/web/shared.ts
T
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
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>
2026-08-18 19:10:39 +00:00

401 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
};