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>
This commit is contained in:
+400
@@ -0,0 +1,400 @@
|
||||
// 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);
|
||||
};
|
||||
Reference in New Issue
Block a user