// 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=`. */ export const ASSET_PARAM = 'asset'; /** Which album is open — `?album=`. */ export const ALBUM_PARAM = 'album'; /** Which person is open — `?person=`. */ 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/` when a slug is set, `/share/` otherwise — both are real * routes on the instance, verified against 3.1.0. */ export const shareUrl = ( externalDomain: string | undefined, link: Pick, ): 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 cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape // hatch userMiddleware already supports for