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

@@ -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;
}