@cantera/photo-typestypes
Generic field-photo types for cantera components: categories, media kinds, the photo record with optional geolocation, and cursor pages. The lingua franca photo adapters translate into.
npx shadcn@latest add @cantera/photo-typesThe lingua franca for field photos — the record with its origin category, media kind, capture time, optional geolocation, and always-valid thumbnail — plus cursor pages for progressive loading. Components take these shapes as props and never fetch; adapters translate provider payloads into them.
import { hasLocation, photoCapturedAt, photoCoverage } from '@/lib/photo-types'
import type { FieldPhoto } from '@/lib/photo-types'
const photos: FieldPhoto[] = await loadPhotos()
const { located, total } = photoCoverage(photos)
const pins = photos.filter(hasLocation).map((photo) => ({
id: photo.id,
position: [photo.longitude, photo.latitude],
at: photoCapturedAt(photo),
}))| Export | Type | Description |
|---|---|---|
| FieldPhoto | interface | One photo: id, nullable title, category, media type, takenAt and createdAt as ISO strings, nullable latitude and longitude, a thumbnailUrl that always resolves, and a nullable sourceUrl deep link. |
| PhotoCategory / PHOTO_CATEGORIES / PHOTO_CATEGORY_LABELS | union, tuple, record | The origin module a photo was added with — field report, issue, form, RFI, gallery, asset, meeting, submittal, other — with display labels. |
| PhotoMediaType / PHOTO_MEDIA_TYPES | union, tuple | The capture kind: photo, video, photosphere, infrared. |
| PhotoPage | interface | One page of a listing: photos plus an opaque nextCursor, null on the last page, for progressive loading over serial cursors. |
| photoCapturedAt | (photo: Pick<FieldPhoto, 'takenAt' | 'createdAt'>) => number | null | The moment a photo represents as epoch milliseconds: EXIF capture time when known, creation time otherwise, null when neither parses. |
| hasLocation | (photo: FieldPhoto) => photo is LocatedPhoto | Narrows to photos with both coordinates. |
| photoCoverage | (photos: FieldPhoto[]) => PhotoCoverage | The located and total counts a surface shows together, so the photos that cannot be placed are never silently dropped. |
This is the exact code the CLI installs into your project — you own it from there.
/** The origin module a photo was added with, normalized across providers. */
export const PHOTO_CATEGORIES = [
'field-report',
'issue',
'form',
'rfi',
'gallery',
'asset',
'meeting',
'submittal',
'other',
] as const
export type PhotoCategory = (typeof PHOTO_CATEGORIES)[number]
export const PHOTO_CATEGORY_LABELS = {
'field-report': 'Field report',
issue: 'Issue',
form: 'Form',
rfi: 'RFI',
gallery: 'Gallery',
asset: 'Asset',
meeting: 'Meeting',
submittal: 'Submittal',
other: 'Other',
} satisfies Record<PhotoCategory, string>
/** The capture kind, normalized across providers. */
export const PHOTO_MEDIA_TYPES = ['photo', 'video', 'photosphere', 'infrared'] as const
export type PhotoMediaType = (typeof PHOTO_MEDIA_TYPES)[number]
export interface FieldPhoto {
id: string
title: string | null
category: PhotoCategory
mediaType: PhotoMediaType
/** ISO datetime the photo was captured (EXIF-derived), when known. */
takenAt: string | null
/** ISO datetime the record was created in the system of record. */
createdAt: string
/** WGS84 decimal degrees; null when the provider has no geolocation. */
latitude: number | null
longitude: number | null
/** A URL that resolves to a viewable thumbnail whenever it is read — a
* static asset, or a proxy route that refreshes short-lived signed URLs. */
thumbnailUrl: string
/** Deep link into the system of record; null when there is none. */
sourceUrl: string | null
}
/** One page of a photo listing, shaped for cursor-driven progressive loading. */
export interface PhotoPage {
photos: FieldPhoto[]
/** Opaque cursor for the next page; null on the last page. */
nextCursor: string | null
}
/** The moment a photo represents: EXIF capture time when the device recorded
* one, the record's creation time otherwise. Null when neither parses, so
* time-based views can drop the photo rather than date it to the epoch. */
export function photoCapturedAt(photo: Pick<FieldPhoto, 'takenAt' | 'createdAt'>): number | null {
const taken = photo.takenAt === null ? Number.NaN : Date.parse(photo.takenAt)
if (Number.isFinite(taken)) return taken
const created = Date.parse(photo.createdAt)
return Number.isFinite(created) ? created : null
}
export type LocatedPhoto = FieldPhoto & { latitude: number; longitude: number }
export function hasLocation(photo: FieldPhoto): photo is LocatedPhoto {
return photo.latitude !== null && photo.longitude !== null
}
export interface PhotoCoverage {
located: number
total: number
}
/** How many photos can be placed on a map. Surfaces show both numbers: the
* honest count is the one that says how many cannot. */
export function photoCoverage(photos: FieldPhoto[]): PhotoCoverage {
let located = 0
for (const photo of photos) {
if (hasLocation(photo)) located += 1
}
return { located, total: photos.length }
}