Files
photos/web/shared.ts
T
pastilhasandClaude Opus 5 70fc2b08c6 a shared link is a URL, not a key
Create, edit and revoke shared links from the album header, the selection
bar and the Sharing panel. Password, expiry, and the three flags that
decide what the person opening it can do.

The panel showed a bare key because this plugin never learned the
instance's public address. It does not need to store one: Immich has
`externalDomain` in its own admin settings, `/server/config` already
returns it, and it is the same field Immich's own copy-link button reads,
so the two cannot disagree. Empty is a real state and the UI says so
rather than inventing a host.

Payload shapes came from the live instance's spec.json, not from memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:16:02 +01:00

467 lines
18 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 SharedLinkType = 'ALBUM' | 'INDIVIDUAL';
/**
* `GET /shared-links` — Immich's `SharedLinkResponseDto`.
*
* `password` really is returned in the clear. That is Immich's choice, not ours, and the edit form uses it:
* showing the owner the password they set beats making them reset it blind to find out what it was.
*/
export type SharedLink = {
id: string;
key: string;
type: SharedLinkType;
album?: Album | null;
assets: Asset[];
description?: string | null;
password: string | null;
slug: string | null;
expiresAt: string | null;
createdAt: string;
allowDownload: boolean;
allowUpload: boolean;
showMetadata: boolean;
};
/**
* `POST /shared-links`. `type` decides which of the next two fields is read: `ALBUM` takes `albumId`,
* `INDIVIDUAL` takes `assetIds`.
*/
export type SharedLinkCreate = {
type: SharedLinkType;
albumId?: string;
assetIds?: string[];
description?: string | null;
password?: string | null;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
};
/** `PATCH /shared-links/{id}`. Every field optional; omitting one leaves it as it was. */
export type SharedLinkEdit = {
description?: string | null;
password?: string | null;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
};
/**
* The URL to hand somebody, or null when Immich does not know its own public address.
*
* The host is Immich's `externalDomain`, not Officer's. A shared link is opened by someone with no Officer
* account, so it has to resolve to the instance itself — and reading it from `/server/config` means Officer
* stores no second copy that could disagree with the one Immich's own copy-link button uses.
*
* Empty is a real state: nobody has set it in Immich's admin settings yet, and the UI says exactly that
* instead of inventing a hostname. `/s/<slug>` when a slug is set, `/share/<key>` otherwise — both are real
* routes on the instance, verified against 3.1.0.
*/
export const shareUrl = (
externalDomain: string | undefined,
link: Pick<SharedLink, 'key' | 'slug'>,
): string | null => {
const base = externalDomain?.trim().replace(/\/+$/, '');
if (!base) return null;
return link.slug ? `${base}/s/${link.slug}` : `${base}/share/${link.key}`;
};
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;
/**
* Immich's own public address, from its admin settings — the one thing standing between a shared link and
* a URL somebody can open. See `shareUrl`.
*/
externalDomain?: 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);
};