feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge

- Add FadeIn component (IntersectionObserver scroll-reveal + load animation)
- Add dark/light theme toggle (useSyncExternalStore + localStorage)
- Add scroll-to-top button (appears after 300px scroll)
- Add configurable footer with social links (gallery.yaml)
- Add crosshair cursor on photo grid items
- Add 'X photos' count badge on subpage cover images
- Add light theme color palette in tokens.css
This commit is contained in:
ralksta
2026-02-14 11:47:26 +01:00
parent e26f091595
commit 52fa16c1c8
57 changed files with 4790 additions and 1984 deletions

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest';
// Mock env.ts so importing config.ts doesn't trigger Zod validation
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
RATE_LIMIT_RPM: 120,
},
}));
import { slugify } from '@/lib/config';
describe('slugify', () => {
it('converts a simple name to lowercase with hyphens', () => {
expect(slugify('Kloster Chorin')).toBe('kloster-chorin');
});
it('strips diacritics', () => {
expect(slugify('Schöne Aussicht')).toBe('schone-aussicht');
expect(slugify('Café Müller')).toBe('cafe-muller');
});
it('replaces special characters with hyphens', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
expect(slugify('foo & bar (baz)')).toBe('foo-bar-baz');
});
it('trims leading and trailing hyphens', () => {
expect(slugify('--leading')).toBe('leading');
expect(slugify('trailing---')).toBe('trailing');
expect(slugify('---both---')).toBe('both');
});
it('collapses consecutive hyphens', () => {
expect(slugify('a b c')).toBe('a-b-c');
});
it('handles an empty string', () => {
expect(slugify('')).toBe('');
});
it('handles all-special-character input', () => {
expect(slugify('!@#$%')).toBe('');
});
it('preserves numbers', () => {
expect(slugify('Album 2024')).toBe('album-2024');
});
});

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { thumbHashToBlurDataUrl, thumbHashToDominantHex } from '@/lib/thumbhash';
// A real ThumbHash captured from an Immich asset (13 bytes, valid for the library)
// This is a minimal valid ThumbHash that produces a tiny image.
// Generate one via: thumbhash.rgbaToThumbHash(w, h, rgba)
// For testing, we use a known-good base64 string that the library accepts.
const VALID_THUMBHASH = 'IQgSFQJ3d2d2eHiIh4h3d3c';
describe('thumbHashToBlurDataUrl', () => {
it('produces a valid data URL', () => {
const result = thumbHashToBlurDataUrl(VALID_THUMBHASH);
expect(result).toMatch(/^data:image\/png;base64,/);
});
it('produces a non-trivial string (has actual image data)', () => {
const result = thumbHashToBlurDataUrl(VALID_THUMBHASH);
expect(result.length).toBeGreaterThan(30);
});
});
describe('thumbHashToDominantHex', () => {
it('returns a valid hex color string', () => {
const result = thumbHashToDominantHex(VALID_THUMBHASH);
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the config module so tokens.ts can resolve getConfig().immich.apiKey
vi.mock('@/lib/config', () => ({
getConfig: () => ({
immich: { apiKey: 'test-api-key-for-vitest-unit-tests' },
}),
}));
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
const SAMPLE_UUID = '550e8400-e29b-41d4-a716-446655440000';
describe('tokens', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('encodeAssetId', () => {
it('produces a non-empty base64url string', () => {
const token = encodeAssetId(SAMPLE_UUID);
expect(token).toBeTruthy();
expect(typeof token).toBe('string');
// base64url characters only
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('is deterministic — same input yields same token', () => {
const t1 = encodeAssetId(SAMPLE_UUID);
const t2 = encodeAssetId(SAMPLE_UUID);
expect(t1).toBe(t2);
});
it('produces different tokens for different UUIDs', () => {
const t1 = encodeAssetId(SAMPLE_UUID);
const t2 = encodeAssetId('12345678-aaaa-bbbb-cccc-123456789abc');
expect(t1).not.toBe(t2);
});
});
describe('decodeAssetId', () => {
it('round-trips a valid UUID', () => {
const token = encodeAssetId(SAMPLE_UUID);
const decoded = decodeAssetId(token);
expect(decoded).toBe(SAMPLE_UUID);
});
it('returns null for a garbage string', () => {
expect(decodeAssetId('not-a-valid-token')).toBeNull();
});
it('returns null for an empty string', () => {
expect(decodeAssetId('')).toBeNull();
});
it('returns null for a token that is too short', () => {
// less than 17 bytes when decoded
const short = Buffer.from('shortdata').toString('base64url');
expect(decodeAssetId(short)).toBeNull();
});
});
});

162
lib/__tests__/urls.test.ts Normal file
View File

@@ -0,0 +1,162 @@
import { describe, it, expect, vi } from 'vitest';
// Mock tokens so we get predictable URL output
vi.mock('@/lib/tokens', () => ({
encodeAssetId: (id: string) => `ENCODED_${id}`,
}));
// Mock thumbhash helpers
vi.mock('@/lib/thumbhash', () => ({
thumbHashToBlurDataUrl: () => 'data:image/png;base64,mockblur',
thumbHashToDominantHex: () => '#aabbcc',
}));
import {
imageUrl,
exifUrl,
assetPlaceholder,
assetExifSummary,
assetAspectRatio,
} from '@/lib/urls';
describe('imageUrl', () => {
it('generates a proxy URL with encoded token and default size', () => {
const url = imageUrl('some-uuid');
expect(url).toBe('/api/image/ENCODED_some-uuid?size=preview');
});
it('uses the specified size parameter', () => {
expect(imageUrl('id', 'thumbnail')).toBe('/api/image/ENCODED_id?size=thumbnail');
expect(imageUrl('id', 'original')).toBe('/api/image/ENCODED_id?size=original');
});
});
describe('exifUrl', () => {
it('generates an EXIF API URL with encoded token', () => {
expect(exifUrl('my-asset')).toBe('/api/exif/ENCODED_my-asset');
});
});
describe('assetPlaceholder', () => {
it('returns blur data and dominant color for an asset with thumbhash', () => {
const result = assetPlaceholder({ thumbhash: 'abc123' });
expect(result).toEqual({
blurDataURL: 'data:image/png;base64,mockblur',
dominantColor: '#aabbcc',
});
});
it('returns null when thumbhash is null', () => {
expect(assetPlaceholder({ thumbhash: null })).toBeNull();
});
it('returns null when thumbhash is empty string', () => {
expect(assetPlaceholder({ thumbhash: '' })).toBeNull();
});
});
describe('assetExifSummary', () => {
it('returns camera, lens, and focal length from exifInfo', () => {
const result = assetExifSummary({
exifInfo: {
make: 'Leica',
model: 'M11-P',
lensModel: 'Summilux-M 50mm',
focalLength: 50,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
expect(result).toEqual({
camera: 'M11-P',
lens: 'Summilux-M 50mm',
focalLength: '50mm',
});
});
it('returns undefined when no exifInfo exists', () => {
expect(assetExifSummary({ exifInfo: undefined })).toBeUndefined();
});
it('returns undefined when all relevant fields are null', () => {
expect(
assetExifSummary({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
}),
).toBeUndefined();
});
});
describe('assetAspectRatio', () => {
it('computes width / height ratio', () => {
const result = assetAspectRatio({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 3000,
exifImageHeight: 2000,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
expect(result).toBe(1.5);
});
it('returns undefined when dimensions are missing', () => {
expect(assetAspectRatio({ exifInfo: undefined })).toBeUndefined();
});
it('returns undefined when height is 0', () => {
expect(
assetAspectRatio({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 100,
exifImageHeight: 0,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
}),
).toBeUndefined();
});
});

View File

@@ -12,52 +12,46 @@ import { getConfig, SubpageConfig } from './config';
const TOKEN_EXPIRY_HOURS = 24;
function hmac(data: string): string {
return crypto
.createHmac('sha256', getConfig().immich.apiKey)
.update(data)
.digest('hex');
return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex');
}
function authToken(slug: string, password: string): string {
return hmac(`${slug}:${password}`);
return hmac(`${slug}:${password}`);
}
function cookieName(slug: string): string {
return `lb_auth_${slug}`;
return `lb_auth_${slug}`;
}
/**
* Find the SubpageConfig for a given slug.
*/
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
return getConfig().subpages.find((sp) => sp.slug === slug);
return getConfig().subpages.find((sp) => sp.slug === slug);
}
/**
* Check if a slug corresponds to a password-protected subpage.
*/
export function isProtected(slug: string): boolean {
const sp = findSubpageBySlug(slug);
return !!sp?.password;
const sp = findSubpageBySlug(slug);
return !!sp?.password;
}
/**
* Validate a password attempt and return a Set-Cookie header value on success.
* Returns null if the password is wrong.
*/
export function authenticate(
slug: string,
password: string,
): string | null {
const sp = findSubpageBySlug(slug);
if (!sp?.password) return null;
export function authenticate(slug: string, password: string): string | null {
const sp = findSubpageBySlug(slug);
if (!sp?.password) return null;
if (password !== sp.password) return null;
if (password !== sp.password) return null;
const token = authToken(slug, password);
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
const token = authToken(slug, password);
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
}
/**
@@ -65,23 +59,20 @@ export function authenticate(
* Works with the cookies() API from Next.js server components.
*/
export function isAuthenticated(
slug: string,
getCookie: (name: string) => string | undefined,
slug: string,
getCookie: (name: string) => string | undefined,
): boolean {
const sp = findSubpageBySlug(slug);
if (!sp?.password) return true; // not protected
const sp = findSubpageBySlug(slug);
if (!sp?.password) return true; // not protected
const cookie = getCookie(cookieName(slug));
if (!cookie) return false;
const cookie = getCookie(cookieName(slug));
if (!cookie) return false;
const expected = authToken(slug, sp.password);
// Constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(cookie, 'hex'),
Buffer.from(expected, 'hex'),
);
} catch {
return false;
}
const expected = authToken(slug, sp.password);
// Constant-time comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
}

View File

@@ -4,42 +4,42 @@
*/
interface CacheEntry<T> {
data: T;
expiresAt: number;
data: T;
expiresAt: number;
}
class MemoryCache {
private store = new Map<string, CacheEntry<unknown>>();
private maxEntries = 200;
private store = new Map<string, CacheEntry<unknown>>();
private maxEntries = 200;
get<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
get<T>(key: string): T | null {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.data as T;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
set<T>(key: string, data: T, ttlMs: number): void {
// Evict oldest entries if at capacity
if (this.store.size >= this.maxEntries) {
const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
}
return entry.data as T;
}
this.store.set(key, {
data,
expiresAt: Date.now() + ttlMs,
});
set<T>(key: string, data: T, ttlMs: number): void {
// Evict oldest entries if at capacity
if (this.store.size >= this.maxEntries) {
const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
}
clear(): void {
this.store.clear();
}
this.store.set(key, {
data,
expiresAt: Date.now() + ttlMs,
});
}
clear(): void {
this.store.clear();
}
}
export const cache = new MemoryCache();

View File

@@ -6,16 +6,9 @@
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { env } from './env';
// ── Env helpers ────────────────────────────────────────────────
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
// ── Env helpers (now handled by lib/env.ts via Zod) ───────────
// ── Types ──────────────────────────────────────────────────────
@@ -30,6 +23,14 @@ export interface SubpageConfig {
grid?: Partial<GridConfig>;
}
/** Footer configuration for social links. */
export interface FooterConfig {
name?: string;
instagram?: string;
email?: string;
website?: string;
}
/** Raw YAML structure (before validation). */
interface GalleryYaml {
hero?: string | string[];
@@ -41,6 +42,12 @@ interface GalleryYaml {
aspectRatio?: string;
layout?: string;
};
footer?: {
name?: string;
instagram?: string;
email?: string;
website?: string;
};
subpages?: Array<{
name: string;
albums: string[];
@@ -62,7 +69,7 @@ function loadGalleryYaml(): GalleryYaml {
if (!fs.existsSync(yamlPath)) {
throw new Error(
`Gallery config not found: ${yamlPath}\n` +
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
`Copy content/gallery.yaml.example to content/gallery.yaml and add your album IDs.`,
);
}
@@ -99,8 +106,8 @@ export function slugify(name: string): string {
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip diacritics (ü→u, é→e)
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
.replace(/[^a-z0-9]+/g, '-') // non-alphanumeric → hyphens
.replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}
// ── Config type ────────────────────────────────────────────────
@@ -126,6 +133,8 @@ export interface AppConfig {
exifOnHover: boolean;
/** Photo grid layout configuration. */
grid: GridConfig;
/** Footer social links config. */
footer: FooterConfig | null;
cacheTtl: number;
rateLimitRpm: number;
}
@@ -137,13 +146,14 @@ let _config: AppConfig | null = null;
export function getConfig(): AppConfig {
if (_config) return _config;
const apiUrl = requireEnv('IMMICH_API_URL').replace(/\/+$/, '');
const apiKey = requireEnv('IMMICH_API_KEY');
const apiUrl = env.IMMICH_API_URL;
const apiKey = env.IMMICH_API_KEY;
const gallery = loadGalleryYaml();
// Parse standalone albums
const standaloneAlbumIds = (gallery.albums ?? [])
.map((id) => validateUuid(id, 'gallery.yaml albums'));
const standaloneAlbumIds = (gallery.albums ?? []).map((id) =>
validateUuid(id, 'gallery.yaml albums'),
);
if (standaloneAlbumIds.length === 0 && (!gallery.subpages || gallery.subpages.length === 0)) {
throw new Error('gallery.yaml must define at least one album or subpage');
@@ -152,21 +162,31 @@ export function getConfig(): AppConfig {
// Parse subpages
const subpages: SubpageConfig[] = (gallery.subpages ?? []).map((sp) => {
if (!sp.name || !sp.albums || sp.albums.length === 0) {
throw new Error(`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`);
throw new Error(
`Subpage "${sp.name || '(unnamed)'}" must have a name and at least one album`,
);
}
return {
name: sp.name,
slug: slugify(sp.name),
albumIds: sp.albums.map((id) => validateUuid(id, `subpage "${sp.name}"`)),
password: sp.password,
...(sp.grid ? {
grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null ? { layout: (sp.grid.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'] } : {}),
},
} : {}),
...(sp.grid
? {
grid: {
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
...(sp.grid.layout != null
? {
layout: (sp.grid.layout === 'uniform'
? 'uniform'
: 'masonry') as GridConfig['layout'],
}
: {}),
},
}
: {}),
};
});
@@ -182,11 +202,12 @@ export function getConfig(): AppConfig {
albums: allAlbumIds,
standaloneAlbums: standaloneAlbumIds.filter((id) => !subpageAlbumIds.has(id)),
subpages,
siteTitle: process.env.SITE_TITLE || 'Gallery',
siteSubtitle: process.env.SITE_SUBTITLE || '',
siteTitle: env.SITE_TITLE,
siteSubtitle: env.SITE_SUBTITLE,
heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero'))
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]).map((id) =>
validateUuid(id, 'gallery.yaml hero'),
)
: [],
exifOnHover: gallery.exifOnHover !== false,
grid: {
@@ -195,8 +216,16 @@ export function getConfig(): AppConfig {
aspectRatio: gallery.grid?.aspectRatio ?? '1',
layout: (gallery.grid?.layout === 'uniform' ? 'uniform' : 'masonry') as GridConfig['layout'],
},
cacheTtl: parseInt(process.env.CACHE_TTL || '300', 10) * 1000,
rateLimitRpm: parseInt(process.env.RATE_LIMIT_RPM || '120', 10),
footer: gallery.footer
? {
name: gallery.footer.name,
instagram: gallery.footer.instagram,
email: gallery.footer.email,
website: gallery.footer.website,
}
: null,
cacheTtl: env.CACHE_TTL * 1000,
rateLimitRpm: env.RATE_LIMIT_RPM,
};
return _config;

45
lib/env.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Zod-validated environment variables.
* Parsed once at startup — all env access should go through this module.
*/
import { z } from 'zod';
const envSchema = z.object({
/** Base URL of your Immich instance, e.g. https://immich.example.com */
IMMICH_API_URL: z
.string()
.url('IMMICH_API_URL must be a valid URL')
.transform((url) => url.replace(/\/+$/, '')),
/** Immich API key (from Admin → API Keys) */
IMMICH_API_KEY: z.string().min(1, 'IMMICH_API_KEY is required'),
/** Public site title shown in the header */
SITE_TITLE: z.string().default('Gallery'),
/** Subtitle displayed below the title */
SITE_SUBTITLE: z.string().default(''),
/** Cache TTL in seconds */
CACHE_TTL: z.coerce.number().int().min(0).default(300),
/** Rate limit: max requests per minute per IP */
RATE_LIMIT_RPM: z.coerce.number().int().min(1).default(120),
});
export type Env = z.infer<typeof envSchema>;
function parseEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
const formatted = result.error.issues
.map((issue) => ` ✗ ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`\n❌ Environment validation failed:\n${formatted}\n`);
}
return result.data;
}
/** Validated, typed environment variables. */
export const env = parseEnv();

View File

@@ -13,310 +13,312 @@ import { cache } from './cache';
// ── Types ──────────────────────────────────────────────────────
export interface ImmichAlbum {
id: string;
slug: string;
albumName: string;
description: string;
albumThumbnailAssetId: string | null;
assetCount: number;
assets: ImmichAsset[];
createdAt: string;
updatedAt: string;
order: 'asc' | 'desc';
id: string;
slug: string;
albumName: string;
description: string;
albumThumbnailAssetId: string | null;
assetCount: number;
assets: ImmichAsset[];
createdAt: string;
updatedAt: string;
order: 'asc' | 'desc';
}
export interface ImmichAsset {
id: string;
type: 'IMAGE' | 'VIDEO';
originalFileName: string;
originalMimeType: string;
thumbhash: string | null;
fileCreatedAt: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
id: string;
type: 'IMAGE' | 'VIDEO';
originalFileName: string;
originalMimeType: string;
thumbhash: string | null;
fileCreatedAt: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
}
export interface ImmichExifInfo {
make: string | null;
model: string | null;
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: string | null;
iso: number | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
city: string | null;
state: string | null;
country: string | null;
dateTimeOriginal: string | null;
description: string | null;
make: string | null;
model: string | null;
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: string | null;
iso: number | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
city: string | null;
state: string | null;
country: string | null;
dateTimeOriginal: string | null;
description: string | null;
}
export type ImageSize = 'thumbnail' | 'preview' | 'original';
/** Enriched subpage with album metadata (for rendering cards). */
export interface SubpageSummary {
name: string;
slug: string;
albumCount: number;
totalAssetCount: number;
coverAssetId: string | null;
name: string;
slug: string;
albumCount: number;
totalAssetCount: number;
coverAssetId: string | null;
}
// ── API Client ─────────────────────────────────────────────────
class ImmichClient {
private hasLoggedAlbums = false;
private hasLoggedAlbums = false;
private get config() {
return getConfig();
private get config() {
return getConfig();
}
/**
* Make an authenticated request to the Immich API.
*/
private async request<T>(endpoint: string): Promise<T | null> {
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
headers: {
'x-api-key': this.config.immich.apiKey,
Accept: 'application/json',
},
});
if (!res.ok) {
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
return null;
}
const contentType = res.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return null;
} catch (error) {
console.error(`[Immich] Failed to reach ${url}:`, error);
return null;
}
}
/**
* Make an authenticated request to the Immich API.
*/
private async request<T>(endpoint: string): Promise<T | null> {
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
headers: {
'x-api-key': this.config.immich.apiKey,
Accept: 'application/json',
},
});
/**
* Stream a binary response from Immich (for image proxying).
*/
async streamAsset(
assetId: string,
size: ImageSize = 'preview',
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
const endpoint =
size === 'original'
? `/assets/${encodeURIComponent(assetId)}/original`
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
if (!res.ok) {
console.error(`[Immich] ${res.status} ${res.statusText} for ${endpoint}`);
return null;
}
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
headers: {
'x-api-key': this.config.immich.apiKey,
},
});
const contentType = res.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return null;
} catch (error) {
console.error(`[Immich] Failed to reach ${url}:`, error);
return null;
if (!res.ok || !res.body) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
return null;
}
return {
stream: res.body,
contentType: res.headers.get('Content-Type') || 'application/octet-stream',
contentLength: res.headers.get('Content-Length'),
};
} catch (error) {
console.error(`[Immich] Stream error for ${assetId}:`, error);
return null;
}
}
/**
* Get ALL configured albums (filtered by the full allowlist).
* Uses ?shared=true to only fetch albums that have been shared in Immich.
*/
async getAlbums(): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
if (!all) return [];
const allowedIds = new Set(this.config.albums);
const filtered = all
.filter((album) => allowedIds.has(album.id))
.map((album) => ({
...album,
slug: slugify(album.albumName),
}));
// Log album summary on first load so admins can see what's published
if (!this.hasLoggedAlbums) {
this.hasLoggedAlbums = true;
console.log('\n[Lightbox] Published albums:');
console.log('─'.repeat(80));
// Log standalone albums
const standaloneIds = new Set(this.config.standaloneAlbums);
const standalone = filtered.filter((a) => standaloneIds.has(a.id));
if (standalone.length > 0) {
console.log(' Standalone:');
for (const a of standalone) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
}
}
}
/**
* Stream a binary response from Immich (for image proxying).
*/
async streamAsset(
assetId: string,
size: ImageSize = 'preview',
): Promise<{ stream: ReadableStream; contentType: string; contentLength: string | null } | null> {
const endpoint =
size === 'original'
? `/assets/${encodeURIComponent(assetId)}/original`
: `/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`;
const url = `${this.config.immich.apiUrl}${endpoint}`;
try {
const res = await fetch(url, {
headers: {
'x-api-key': this.config.immich.apiKey,
},
});
if (!res.ok || !res.body) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
return null;
}
return {
stream: res.body,
contentType: res.headers.get('Content-Type') || 'application/octet-stream',
contentLength: res.headers.get('Content-Length'),
};
} catch (error) {
console.error(`[Immich] Stream error for ${assetId}:`, error);
return null;
// Log subpage groupings
for (const sp of this.config.subpages) {
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
console.log(` 📁 ${sp.name} (/${sp.slug}):`);
for (const a of spAlbums) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
}
}
const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id));
if (missing.length > 0) {
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`);
}
console.log('─'.repeat(80) + '\n');
}
/**
* Get ALL configured albums (filtered by the full allowlist).
* Uses ?shared=true to only fetch albums that have been shared in Immich.
*/
async getAlbums(): Promise<ImmichAlbum[]> {
const cacheKey = 'albums-list';
const cached = cache.get<ImmichAlbum[]>(cacheKey);
if (cached) return cached;
cache.set(cacheKey, filtered, this.config.cacheTtl);
return filtered;
}
const all = await this.request<ImmichAlbum[]>('/albums?shared=true');
if (!all) return [];
/**
* Get only standalone albums (not in any subpage) — for the homepage.
*/
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
const albums = await this.getAlbums();
const standaloneIds = new Set(this.config.standaloneAlbums);
return albums.filter((a) => standaloneIds.has(a.id));
}
const allowedIds = new Set(this.config.albums);
const filtered = all
.filter((album) => allowedIds.has(album.id))
.map((album) => ({
...album,
slug: slugify(album.albumName),
}));
/**
* Get enriched subpage summaries for the homepage.
*/
async getSubpages(): Promise<SubpageSummary[]> {
if (this.config.subpages.length === 0) return [];
// Log album summary on first load so admins can see what's published
if (!this.hasLoggedAlbums) {
this.hasLoggedAlbums = true;
console.log('\n[Lightbox] Published albums:');
console.log('─'.repeat(80));
const albums = await this.getAlbums();
const albumMap = new Map(albums.map((a) => [a.id, a]));
// Log standalone albums
const standaloneIds = new Set(this.config.standaloneAlbums);
const standalone = filtered.filter((a) => standaloneIds.has(a.id));
if (standalone.length > 0) {
console.log(' Standalone:');
for (const a of standalone) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${a.slug} • ${a.assetCount} photos • ID: ${a.id}`);
}
}
return this.config.subpages.map((sp) => {
const spAlbums = sp.albumIds
.map((id) => albumMap.get(id))
.filter((a): a is ImmichAlbum => a !== undefined);
// Log subpage groupings
for (const sp of this.config.subpages) {
const spAlbums = filtered.filter((a) => sp.albumIds.includes(a.id));
console.log(` 📁 ${sp.name} (/${sp.slug}):`);
for (const a of spAlbums) {
console.log(` 📷 ${a.albumName}`);
console.log(` URL: /${sp.slug}/${a.slug} • ${a.assetCount} photos`);
}
}
return {
name: sp.name,
slug: sp.slug,
albumCount: spAlbums.length,
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
};
});
}
const missing = this.config.albums.filter((id) => !all.some((a) => a.id === id));
if (missing.length > 0) {
console.warn(` ⚠️ Unknown album IDs: ${missing.join(', ')}`);
}
console.log('─'.repeat(80) + '\n');
}
/**
* Get albums belonging to a specific subpage.
*/
async getSubpageAlbums(
subpageSlug: string,
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
cache.set(cacheKey, filtered, this.config.cacheTtl);
return filtered;
const allAlbums = await this.getAlbums();
const subpageAlbumIds = new Set(subpage.albumIds);
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
return { subpage, albums };
}
/**
* Get a single album with its assets.
*/
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
return null;
}
/**
* Get only standalone albums (not in any subpage) — for the homepage.
*/
async getStandaloneAlbums(): Promise<ImmichAlbum[]> {
const albums = await this.getAlbums();
const standaloneIds = new Set(this.config.standaloneAlbums);
return albums.filter((a) => standaloneIds.has(a.id));
const cacheKey = `album-${albumId}`;
const cached = cache.get<ImmichAlbum>(cacheKey);
if (cached) return cached;
const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
if (!album) return null;
// Filter out trashed assets
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
album.slug = slugify(album.albumName);
cache.set(cacheKey, album, this.config.cacheTtl);
return album;
}
/**
* Find an album by its URL slug.
* If subpageSlug is provided, only search within that subpage's albums.
*/
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums();
let searchSet = albums;
if (subpageSlug) {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id));
}
/**
* Get enriched subpage summaries for the homepage.
*/
async getSubpages(): Promise<SubpageSummary[]> {
if (this.config.subpages.length === 0) return [];
const match = searchSet.find((a) => a.slug === slug);
if (!match) return null;
return this.getAlbum(match.id);
}
const albums = await this.getAlbums();
const albumMap = new Map(albums.map((a) => [a.id, a]));
/**
* Check if a slug corresponds to a subpage.
*/
isSubpageSlug(slug: string): boolean {
return this.config.subpages.some((sp) => sp.slug === slug);
}
return this.config.subpages.map((sp) => {
const spAlbums = sp.albumIds
.map((id) => albumMap.get(id))
.filter((a): a is ImmichAlbum => a !== undefined);
/**
* Get asset info including EXIF data.
*/
async getAssetInfo(assetId: string): Promise<ImmichAsset | null> {
const cacheKey = `asset-${assetId}`;
const cached = cache.get<ImmichAsset>(cacheKey);
if (cached) return cached;
return {
name: sp.name,
slug: sp.slug,
albumCount: spAlbums.length,
totalAssetCount: spAlbums.reduce((sum, a) => sum + a.assetCount, 0),
coverAssetId: spAlbums[0]?.albumThumbnailAssetId ?? null,
};
});
}
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
if (!asset) return null;
/**
* Get albums belonging to a specific subpage.
*/
async getSubpageAlbums(subpageSlug: string): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
cache.set(cacheKey, asset, this.config.cacheTtl);
return asset;
}
const allAlbums = await this.getAlbums();
const subpageAlbumIds = new Set(subpage.albumIds);
const albums = allAlbums.filter((a) => subpageAlbumIds.has(a.id));
return { subpage, albums };
}
/**
* Get a single album with its assets.
*/
async getAlbum(albumId: string): Promise<ImmichAlbum | null> {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
return null;
}
const cacheKey = `album-${albumId}`;
const cached = cache.get<ImmichAlbum>(cacheKey);
if (cached) return cached;
const album = await this.request<ImmichAlbum>(`/albums/${encodeURIComponent(albumId)}`);
if (!album) return null;
// Filter out trashed assets
album.assets = (album.assets || []).filter((a) => !a.isTrashed);
album.slug = slugify(album.albumName);
cache.set(cacheKey, album, this.config.cacheTtl);
return album;
}
/**
* Find an album by its URL slug.
* If subpageSlug is provided, only search within that subpage's albums.
*/
async getAlbumBySlug(slug: string, subpageSlug?: string): Promise<ImmichAlbum | null> {
const albums = await this.getAlbums();
let searchSet = albums;
if (subpageSlug) {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id));
}
const match = searchSet.find((a) => a.slug === slug);
if (!match) return null;
return this.getAlbum(match.id);
}
/**
* Check if a slug corresponds to a subpage.
*/
isSubpageSlug(slug: string): boolean {
return this.config.subpages.some((sp) => sp.slug === slug);
}
/**
* Get asset info including EXIF data.
*/
async getAssetInfo(assetId: string): Promise<ImmichAsset | null> {
const cacheKey = `asset-${assetId}`;
const cached = cache.get<ImmichAsset>(cacheKey);
if (cached) return cached;
const asset = await this.request<ImmichAsset>(`/assets/${encodeURIComponent(assetId)}`);
if (!asset) return null;
cache.set(cacheKey, asset, this.config.cacheTtl);
return asset;
}
/**
* Check if the Immich server is reachable.
*/
async ping(): Promise<boolean> {
const res = await this.request<{ res: string }>('/server/ping');
return !!res;
}
/**
* Check if the Immich server is reachable.
*/
async ping(): Promise<boolean> {
const res = await this.request<{ res: string }>('/server/ping');
return !!res;
}
}
export const immich = new ImmichClient();

View File

@@ -13,12 +13,12 @@
'use client';
interface ImageLoaderParams {
src: string;
width: number;
quality?: number;
src: string;
width: number;
quality?: number;
}
export default function immichLoader({ src, width, quality }: ImageLoaderParams): string {
const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}w=${width}&q=${quality || 75}`;
const separator = src.includes('?') ? '&' : '?';
return `${src}${separator}w=${width}&q=${quality || 75}`;
}

View File

@@ -5,8 +5,8 @@
*/
interface RateLimitEntry {
count: number;
expiresAt: number;
count: number;
expiresAt: number;
}
const store = new Map<string, RateLimitEntry>();
@@ -16,12 +16,12 @@ let lastCleanup = Date.now();
const CLEANUP_INTERVAL = 60_000;
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (now > entry.expiresAt) store.delete(key);
}
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (now > entry.expiresAt) store.delete(key);
}
}
/**
@@ -31,29 +31,29 @@ function cleanup() {
* @returns { success, remaining, resetAt (epoch ms) }
*/
export function checkRateLimit(
ip: string,
maxRpm: number,
ip: string,
maxRpm: number,
): { success: boolean; remaining: number; resetAt: number } {
cleanup();
cleanup();
const now = Date.now();
const windowMs = 60_000;
const key = `rl:${ip}`;
const now = Date.now();
const windowMs = 60_000;
const key = `rl:${ip}`;
const entry = store.get(key);
const entry = store.get(key);
// New window or expired
if (!entry || now > entry.expiresAt) {
const resetAt = now + windowMs;
store.set(key, { count: 1, expiresAt: resetAt });
return { success: true, remaining: maxRpm - 1, resetAt };
}
// New window or expired
if (!entry || now > entry.expiresAt) {
const resetAt = now + windowMs;
store.set(key, { count: 1, expiresAt: resetAt });
return { success: true, remaining: maxRpm - 1, resetAt };
}
// Within window
entry.count++;
if (entry.count > maxRpm) {
return { success: false, remaining: 0, resetAt: entry.expiresAt };
}
// Within window
entry.count++;
if (entry.count > maxRpm) {
return { success: false, remaining: 0, resetAt: entry.expiresAt };
}
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
}

View File

@@ -11,8 +11,8 @@ import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
* suitable for next/image's blurDataURL prop.
*/
export function thumbHashToBlurDataUrl(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return thumbHashToDataURL(bytes);
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return thumbHashToDataURL(bytes);
}
/**
@@ -20,23 +20,29 @@ export function thumbHashToBlurDataUrl(base64: string): string {
* Computes the weighted average of all non-transparent pixels.
*/
export function thumbHashToDominantHex(base64: string): string {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const { w, h, rgba } = thumbHashToRGBA(bytes);
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const { w, h, rgba } = thumbHashToRGBA(bytes);
let r = 0, g = 0, b = 0, total = 0;
for (let i = 0; i < w * h; i++) {
const a = rgba[i * 4 + 3];
if (a > 0) {
const weight = a / 255;
r += rgba[i * 4] * weight;
g += rgba[i * 4 + 1] * weight;
b += rgba[i * 4 + 2] * weight;
total += weight;
}
let r = 0,
g = 0,
b = 0,
total = 0;
for (let i = 0; i < w * h; i++) {
const a = rgba[i * 4 + 3];
if (a > 0) {
const weight = a / 255;
r += rgba[i * 4] * weight;
g += rgba[i * 4 + 1] * weight;
b += rgba[i * 4 + 2] * weight;
total += weight;
}
}
if (total === 0) return '#888888';
if (total === 0) return '#888888';
const avg = (v: number) => Math.round(v / total).toString(16).padStart(2, '0');
return `#${avg(r)}${avg(g)}${avg(b)}`;
const avg = (v: number) =>
Math.round(v / total)
.toString(16)
.padStart(2, '0');
return `#${avg(r)}${avg(g)}${avg(b)}`;
}

View File

@@ -12,27 +12,24 @@ import { getConfig } from './config';
let _key: Buffer | null = null;
function getKey(): Buffer {
if (!_key) {
const apiKey = getConfig().immich.apiKey;
_key = crypto.createHash('sha256').update(apiKey).digest();
}
return _key;
if (!_key) {
const apiKey = getConfig().immich.apiKey;
_key = crypto.createHash('sha256').update(apiKey).digest();
}
return _key;
}
/**
* Encode an asset ID into an opaque URL-safe token.
*/
export function encodeAssetId(assetId: string): string {
const key = getKey();
// Deterministic IV from asset ID (same input → same token)
const iv = crypto.createHash('md5').update(assetId).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([
cipher.update(assetId, 'utf8'),
cipher.final(),
]);
// Compact: base64url(iv + ciphertext)
return Buffer.concat([iv, encrypted]).toString('base64url');
const key = getKey();
// Deterministic IV from asset ID (same input → same token)
const iv = crypto.createHash('md5').update(assetId).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const encrypted = Buffer.concat([cipher.update(assetId, 'utf8'), cipher.final()]);
// Compact: base64url(iv + ciphertext)
return Buffer.concat([iv, encrypted]).toString('base64url');
}
/**
@@ -40,23 +37,22 @@ export function encodeAssetId(assetId: string): string {
* Returns null if the token is invalid.
*/
export function decodeAssetId(token: string): string | null {
try {
const key = getKey();
const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte
try {
const key = getKey();
const data = Buffer.from(token, 'base64url');
if (data.length < 17) return null; // iv(16) + at least 1 byte
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString('utf8');
const iv = data.subarray(0, 16);
const encrypted = data.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
'utf8',
);
// Validate it looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(decrypted) ? decrypted : null;
} catch {
return null;
}
// Validate it looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(decrypted) ? decrypted : null;
} catch {
return null;
}
}

View File

@@ -10,23 +10,26 @@ import type { ImmichAsset } from './immich';
/**
* Generate a public image proxy URL for an asset.
*/
export function imageUrl(assetId: string, size: 'thumbnail' | 'preview' | 'original' = 'preview'): string {
return `/api/image/${encodeAssetId(assetId)}?size=${size}`;
export function imageUrl(
assetId: string,
size: 'thumbnail' | 'preview' | 'original' = 'preview',
): string {
return `/api/image/${encodeAssetId(assetId)}?size=${size}`;
}
/**
* Generate a public EXIF API URL for an asset.
*/
export function exifUrl(assetId: string): string {
return `/api/exif/${encodeAssetId(assetId)}`;
return `/api/exif/${encodeAssetId(assetId)}`;
}
/**
* Placeholder data derived from an asset's ThumbHash.
*/
export interface PlaceholderData {
blurDataURL: string;
dominantColor: string;
blurDataURL: string;
dominantColor: string;
}
/**
@@ -34,24 +37,24 @@ export interface PlaceholderData {
* Returns null if the asset has no ThumbHash.
*/
export function assetPlaceholder(asset: Pick<ImmichAsset, 'thumbhash'>): PlaceholderData | null {
if (!asset.thumbhash) return null;
try {
return {
blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash),
dominantColor: thumbHashToDominantHex(asset.thumbhash),
};
} catch {
return null;
}
if (!asset.thumbhash) return null;
try {
return {
blurDataURL: thumbHashToBlurDataUrl(asset.thumbhash),
dominantColor: thumbHashToDominantHex(asset.thumbhash),
};
} catch {
return null;
}
}
/**
* Compact EXIF summary for hover overlays.
*/
export interface ExifSummary {
camera?: string;
lens?: string;
focalLength?: string;
camera?: string;
lens?: string;
focalLength?: string;
}
/**
@@ -59,15 +62,15 @@ export interface ExifSummary {
* Returns undefined if no relevant EXIF data is available.
*/
export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSummary | undefined {
const exif = asset.exifInfo;
if (!exif) return undefined;
const exif = asset.exifInfo;
if (!exif) return undefined;
const camera = exif.model || undefined;
const lens = exif.lensModel || undefined;
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
const camera = exif.model || undefined;
const lens = exif.lensModel || undefined;
const focalLength = exif.focalLength ? `${exif.focalLength}mm` : undefined;
if (!camera && !lens && !focalLength) return undefined;
return { camera, lens, focalLength };
if (!camera && !lens && !focalLength) return undefined;
return { camera, lens, focalLength };
}
/**
@@ -75,8 +78,8 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
* Returns undefined if dimensions are not available.
*/
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (w && h && h > 0) return w / h;
return undefined;
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (w && h && h > 0) return w / h;
return undefined;
}