release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../env';
|
||||
import { resolveAuthSecret } from '../secret';
|
||||
import { getInstallCredentials } from '../install';
|
||||
@@ -6,6 +8,8 @@ import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
|
||||
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
|
||||
import {
|
||||
slugify,
|
||||
resolveExifDisplay,
|
||||
resolveColorMode,
|
||||
AppConfig,
|
||||
AlbumEntryObject,
|
||||
SubpageConfig,
|
||||
@@ -159,15 +163,17 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
const albumGrids: Record<string, Partial<GridConfig>> = {};
|
||||
const albumCoverPositions: Record<string, string> = {};
|
||||
|
||||
/** Returns null for an entry whose album ID is not a UUID; callers drop it. */
|
||||
function processAlbumEntry(
|
||||
entry: string | Record<string, string | AlbumEntryObject>,
|
||||
context: string,
|
||||
): string {
|
||||
): string | null {
|
||||
if (typeof entry === 'string') {
|
||||
return validateUuid(entry, context);
|
||||
}
|
||||
const [uuid, value] = Object.entries(entry)[0];
|
||||
const validatedUuid = validateUuid(uuid, context);
|
||||
if (!validatedUuid) return null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
albumOverrides[validatedUuid] = value;
|
||||
@@ -182,10 +188,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
// the log naming the cause. Every other ID in this file goes through
|
||||
// validateUuid; this one was the gap.
|
||||
if (value.heroImage) {
|
||||
albumHeroImages[validatedUuid] = validateUuid(
|
||||
const heroId = validateUuid(
|
||||
value.heroImage,
|
||||
`${context} heroImage for album ${validatedUuid}`,
|
||||
);
|
||||
// Dropped rather than defaulted: without one the album falls back to its
|
||||
// Immich cover, which is a better answer than a request that 400s.
|
||||
if (heroId) albumHeroImages[validatedUuid] = heroId;
|
||||
}
|
||||
|
||||
// Throw rather than fall back to the default: a typo would otherwise be
|
||||
@@ -208,9 +217,11 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
// Kept regardless of the mode, so switching manual → newest → manual does
|
||||
// not silently destroy a hand-curated order.
|
||||
if (value.assetOrder?.length) {
|
||||
albumManualOrders[validatedUuid] = value.assetOrder.map((assetId, i) =>
|
||||
validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
|
||||
);
|
||||
albumManualOrders[validatedUuid] = value.assetOrder
|
||||
.map((assetId, i) =>
|
||||
validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
|
||||
)
|
||||
.filter((id): id is string => id !== null);
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: per-album grid override — reuses the subpage-grid
|
||||
@@ -239,9 +250,17 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
return validatedUuid;
|
||||
}
|
||||
|
||||
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
|
||||
processAlbumEntry(entry, 'gallery.yaml albums'),
|
||||
);
|
||||
/** Album entries, with the ones whose ID is not a UUID dropped (#517). */
|
||||
function processAlbumEntries(
|
||||
entries: Array<string | Record<string, string | AlbumEntryObject>>,
|
||||
context: string,
|
||||
): string[] {
|
||||
return entries
|
||||
.map((entry) => processAlbumEntry(entry, context))
|
||||
.filter((id): id is string => id !== null);
|
||||
}
|
||||
|
||||
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
|
||||
|
||||
let subpages: SubpageConfig[] = [];
|
||||
|
||||
@@ -260,23 +279,19 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
title: sec.title,
|
||||
slug: slugify(sec.title),
|
||||
description: sec.description,
|
||||
albumIds: sec.albums.map((entry) =>
|
||||
processAlbumEntry(entry, `subpage "${sp.name}" section "${sec.title}"`),
|
||||
),
|
||||
albumIds: processAlbumEntries(sec.albums, `subpage "${sp.name}" section "${sec.title}"`),
|
||||
}));
|
||||
// flat union of all section albums
|
||||
albumIds = sections.flatMap((s) => s.albumIds);
|
||||
albumIds = sections!.flatMap((sec) => sec.albumIds);
|
||||
// Also include any top-level albums (outside sections)
|
||||
const topLevel = (sp.albums ?? []).map((entry) =>
|
||||
processAlbumEntry(entry, `subpage "${sp.name}"`),
|
||||
);
|
||||
const topLevel = processAlbumEntries(sp.albums ?? [], `subpage "${sp.name}"`);
|
||||
albumIds = [...topLevel, ...albumIds];
|
||||
} else {
|
||||
const albums = sp.albums ?? [];
|
||||
if (albums.length === 0) {
|
||||
throw new Error(`Subpage "${sp.name}" must have albums or sections`);
|
||||
}
|
||||
albumIds = albums.map((entry) => processAlbumEntry(entry, `subpage "${sp.name}"`));
|
||||
albumIds = processAlbumEntries(albums, `subpage "${sp.name}"`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -301,7 +316,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
return {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
albumIds: processAlbumEntries(value, `subpage "${name}"`),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
@@ -312,7 +327,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
slug: slugify(name),
|
||||
title: sp.title,
|
||||
subtitle: sp.subtitle,
|
||||
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
albumIds: processAlbumEntries(albumEntries, `subpage "${name}"`),
|
||||
password: sp.password,
|
||||
proofing: sp.proofing,
|
||||
essayFile: sp.essayFile,
|
||||
@@ -342,6 +357,15 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether content/about.md exists — the About page has nothing to show without it. */
|
||||
function aboutContentExists(): boolean {
|
||||
try {
|
||||
return fs.existsSync(path.join(process.cwd(), 'content', 'about.md'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): AppConfig {
|
||||
// No in-memory config cache: admin saves can land in a different
|
||||
// worker/process than page rendering, so a per-worker cache goes stale
|
||||
@@ -358,7 +382,15 @@ export function getConfig(): AppConfig {
|
||||
const gallery = loadYaml<GalleryYaml>('gallery.yaml');
|
||||
const settings = loadYaml<SettingsYaml>('settings.yaml') || {};
|
||||
|
||||
if (!gallery || !apiKey || !apiUrl) {
|
||||
// Two separate faults, deliberately kept apart. Missing credentials mean
|
||||
// nothing can reach Immich; a missing gallery.yaml only means the public site
|
||||
// has nothing to list yet. Conflating them made a credentialed deployment
|
||||
// without gallery.yaml report "Immich disconnected" and locked the admin
|
||||
// panel out of the album pickers that would have created the file (#507).
|
||||
const needsCredentials = !apiKey || !apiUrl;
|
||||
const needsGallery = !gallery;
|
||||
|
||||
if (needsCredentials || needsGallery) {
|
||||
// Return dummy config if gallery.yaml is missing
|
||||
return {
|
||||
immich: { apiUrl: immichApiUrl, apiKey },
|
||||
@@ -369,6 +401,9 @@ export function getConfig(): AppConfig {
|
||||
siteTitle: env.SITE_TITLE || 'Immich Folio',
|
||||
siteSubtitle: env.SITE_SUBTITLE || 'Setup Required',
|
||||
lang: 'en',
|
||||
// Nothing to protect yet, and a gate here would sit in front of the
|
||||
// setup screen that is trying to tell the operator what is missing.
|
||||
sitePassword: '',
|
||||
seo: {
|
||||
title: 'Setup Required',
|
||||
description: 'Please configure Immich Folio',
|
||||
@@ -377,8 +412,11 @@ export function getConfig(): AppConfig {
|
||||
noFollow: true,
|
||||
},
|
||||
heroImages: [],
|
||||
colorMode: 'dark',
|
||||
exifOnHover: true,
|
||||
exif: resolveExifDisplay(),
|
||||
grid: { columns: 3, gap: 12, aspectRatio: '1', layout: 'masonry' },
|
||||
gridGapExplicit: false,
|
||||
theme: resolveTheme(DEFAULT_PRESET),
|
||||
footer: null,
|
||||
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
||||
@@ -403,6 +441,8 @@ export function getConfig(): AppConfig {
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
|
||||
needsSetup: true,
|
||||
needsCredentials,
|
||||
needsGallery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,6 +473,9 @@ export function getConfig(): AppConfig {
|
||||
siteTitle: settings.title ?? env.SITE_TITLE,
|
||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||
lang: settings.lang ?? 'en',
|
||||
// Env wins, so a deployment can rotate the site password without touching
|
||||
// settings.yaml — the same precedence the Immich credentials use.
|
||||
sitePassword: env.SITE_PASSWORD ?? settings.sitePassword ?? '',
|
||||
seo: {
|
||||
title: siteSeoTitle,
|
||||
description:
|
||||
@@ -445,11 +488,13 @@ export function getConfig(): AppConfig {
|
||||
noFollow: settings.seo?.noFollow === true,
|
||||
},
|
||||
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'))
|
||||
.filter((id): id is string => id !== null)
|
||||
: [],
|
||||
colorMode: resolveColorMode(settings.mode),
|
||||
exifOnHover: settings.exifOnHover !== false,
|
||||
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
|
||||
grid: {
|
||||
columns: settings.grid?.columns ?? 3,
|
||||
gap: settings.grid?.gap ?? 12,
|
||||
@@ -458,6 +503,7 @@ export function getConfig(): AppConfig {
|
||||
? settings.grid!.layout
|
||||
: 'masonry') as GridConfig['layout'],
|
||||
},
|
||||
gridGapExplicit: settings.grid?.gap != null,
|
||||
theme,
|
||||
footer: settings.footer
|
||||
? {
|
||||
@@ -489,7 +535,10 @@ export function getConfig(): AppConfig {
|
||||
},
|
||||
protection: settings.protection,
|
||||
watermark: settings.watermark,
|
||||
aboutEnabled: settings.about?.enabled !== false,
|
||||
// The setting alone is not enough: about.md does not exist until someone
|
||||
// writes it, so a fresh install put a nav link in front of an empty page
|
||||
// (#518). `enabled: false` still hides it even when the file is there.
|
||||
aboutEnabled: settings.about?.enabled !== false && aboutContentExists(),
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
@@ -505,5 +554,7 @@ export function getConfig(): AppConfig {
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
trustedProxyHops: env.TRUSTED_PROXY_HOPS,
|
||||
needsSetup: false,
|
||||
needsCredentials: false,
|
||||
needsGallery: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,13 +41,25 @@ export function loadYaml<T>(filename: string): T | null {
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function validateUuid(id: string, context: string): string {
|
||||
/**
|
||||
* An ID from the config, or `null` when it is not a UUID at all.
|
||||
*
|
||||
* It used to substitute the all-zeros UUID and log "(Ignored for build/setup)",
|
||||
* which was untrue twice over: nothing ignored it, and it was not a build-time
|
||||
* concern. The sentinel travelled on as a real asset ID — encoded into a token,
|
||||
* requested through /api/image, forwarded to Immich, answered with 400 — so a
|
||||
* placeholder left in the example file turned the home page into "Something
|
||||
* went wrong" (#517).
|
||||
*
|
||||
* Returning null lets each caller drop the entry instead, which is what
|
||||
* "ignored" was supposed to mean: one fewer hero image, one album not
|
||||
* published, rather than a request that cannot succeed.
|
||||
*/
|
||||
export function validateUuid(id: string, context: string): string | null {
|
||||
const trimmed = id.trim();
|
||||
if (!UUID_REGEX.test(trimmed)) {
|
||||
// If we are in setup mode or using dummy data, don't crash the build.
|
||||
// Return a valid dummy UUID so Next.js doesn't panic.
|
||||
console.warn(`Invalid UUID in ${context}: "${trimmed}" (Ignored for build/setup)`);
|
||||
return '00000000-0000-0000-0000-000000000000';
|
||||
console.warn(`Invalid UUID in ${context}: "${trimmed}" — entry dropped.`);
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,58 @@ export interface GridConfig {
|
||||
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds for the album-cover grid on a subpage. Shared with the admin inputs so
|
||||
* the form and the renderer cannot drift apart.
|
||||
*/
|
||||
export const COVER_GRID_COLUMNS_MIN = 1;
|
||||
export const COVER_GRID_COLUMNS_MAX = 6;
|
||||
export const COVER_GRID_GAP_MAX = 80;
|
||||
|
||||
/** Tablet widths show at most two covers side by side, like the photo grids. */
|
||||
export const COVER_GRID_TABLET_COLUMNS_MAX = 2;
|
||||
|
||||
/**
|
||||
* The CSS custom properties that size the album-cover grid on a subpage.
|
||||
*
|
||||
* `columns` follows the same config as the photo grids (global `settings.yaml`
|
||||
* < per-subpage override), so "3 columns" in the admin means three columns
|
||||
* everywhere. `gap` deliberately does not: each theme preset picks the cover
|
||||
* spacing as part of its look (1px monograph, 20px studio-modern), so the
|
||||
* variable is only emitted when a subpage sets `gap` explicitly — otherwise the
|
||||
* preset's own `--subpage-gap` stands.
|
||||
*
|
||||
* `--subpage-columns-tablet` is computed here rather than in CSS because
|
||||
* `repeat()` does not reliably accept a `min()` expression.
|
||||
*/
|
||||
export function buildCoverGridVars(
|
||||
overrides: Partial<GridConfig> | undefined,
|
||||
globalColumns: number,
|
||||
): Record<string, string | number> {
|
||||
// A hand-edited `columns: 0` would emit `repeat(0, 1fr)` and collapse the
|
||||
// grid, so the count is clamped rather than trusted.
|
||||
const columns = clamp(
|
||||
overrides?.columns ?? globalColumns,
|
||||
COVER_GRID_COLUMNS_MIN,
|
||||
COVER_GRID_COLUMNS_MAX,
|
||||
);
|
||||
const vars: Record<string, string | number> = {
|
||||
'--subpage-columns': columns,
|
||||
'--subpage-columns-tablet': Math.min(columns, COVER_GRID_TABLET_COLUMNS_MAX),
|
||||
};
|
||||
if (overrides?.gap != null) {
|
||||
vars['--subpage-gap'] = `${clamp(overrides.gap, 0, COVER_GRID_GAP_MAX)}px`;
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
// NaN from a malformed YAML value would survive Math.min/Math.max, and
|
||||
// `repeat(NaN, 1fr)` is an invalid declaration that drops the whole rule.
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, Math.round(value)));
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
immich: { apiUrl: string; apiKey: string };
|
||||
authSecret: string;
|
||||
@@ -108,8 +160,19 @@ export interface AppConfig {
|
||||
noFollow: boolean;
|
||||
};
|
||||
heroImages: string[];
|
||||
/** Colour mode a first-time visitor lands on; their own choice overrides it. */
|
||||
colorMode: ColorMode;
|
||||
exifOnHover: boolean;
|
||||
/** Which EXIF groups the grid hover, the lightbox panel and the album header may show. */
|
||||
exif: ExifDisplayConfig;
|
||||
grid: GridConfig;
|
||||
/**
|
||||
* Whether `grid.gap` was set in settings.yaml. The resolved `grid.gap` cannot
|
||||
* say — it carries the fallback either way — and each theme preset picks its
|
||||
* own photo spacing as part of its look, so the CSS variable is only emitted
|
||||
* when someone actually chose a value (#513).
|
||||
*/
|
||||
gridGapExplicit: boolean;
|
||||
theme: ThemeConfig;
|
||||
footer: FooterConfig | null;
|
||||
legal: LegalConfig;
|
||||
@@ -144,7 +207,28 @@ export interface AppConfig {
|
||||
immichTimeoutMs: number;
|
||||
rateLimitRpm: number;
|
||||
trustedProxyHops: number;
|
||||
/**
|
||||
* True while the app is not fully installed — either credentials or
|
||||
* `gallery.yaml` are missing. Kept as the union of the two flags below for
|
||||
* callers that only care whether the setup screen is due.
|
||||
*/
|
||||
needsSetup?: boolean;
|
||||
/**
|
||||
* No Immich URL or API key. Nothing can talk to Immich, and the setup wizard
|
||||
* is the only way forward.
|
||||
*/
|
||||
needsCredentials?: boolean;
|
||||
/**
|
||||
* Credentials are present but `content/gallery.yaml` is not. The public site
|
||||
* has nothing to show, yet Immich is perfectly reachable — the admin panel
|
||||
* must stay usable so the file can be created from there.
|
||||
*/
|
||||
needsGallery?: boolean;
|
||||
/**
|
||||
* Password for the whole public site, or `''` when the site is open.
|
||||
* Plaintext or `scrypt:salt:hash`, same as every other password in the app.
|
||||
*/
|
||||
sitePassword: string;
|
||||
protection?: {
|
||||
disableRightClick?: boolean;
|
||||
disableImageDrag?: boolean;
|
||||
@@ -217,7 +301,10 @@ export interface SettingsYaml {
|
||||
noIndex?: boolean;
|
||||
noFollow?: boolean;
|
||||
};
|
||||
/** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */
|
||||
mode?: ColorMode;
|
||||
exifOnHover?: boolean;
|
||||
exif?: ExifDisplayYaml;
|
||||
map?: boolean;
|
||||
transitions?: boolean;
|
||||
scrollToTop?: boolean;
|
||||
@@ -252,6 +339,8 @@ export interface SettingsYaml {
|
||||
disableRightClick?: boolean;
|
||||
disableImageDrag?: boolean;
|
||||
};
|
||||
/** Puts the whole public site behind one password. `SITE_PASSWORD` overrides it. */
|
||||
sitePassword?: string;
|
||||
watermark?: {
|
||||
enabled?: boolean;
|
||||
text?: string;
|
||||
@@ -261,6 +350,106 @@ export interface SettingsYaml {
|
||||
about?: { enabled?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which EXIF groups a site publishes, as written in settings.yaml.
|
||||
*
|
||||
* Grouped rather than field-by-field on purpose: a list of individual field
|
||||
* names becomes a small language of its own — it needs validation, an error
|
||||
* path for typos, and an admin control per field — for a choice people make in
|
||||
* groups anyway ("no locations", "no descriptions") (#506).
|
||||
*/
|
||||
/**
|
||||
* The colour mode a visitor sees before they touch the toggle. `auto` follows
|
||||
* the operating system. A visitor's own choice is kept in localStorage and
|
||||
* always wins — this only decides the starting point (#512).
|
||||
*/
|
||||
export type ColorMode = 'light' | 'dark' | 'auto';
|
||||
|
||||
export const COLOR_MODES: ColorMode[] = ['dark', 'light', 'auto'];
|
||||
|
||||
export function resolveColorMode(raw?: string): ColorMode {
|
||||
return (COLOR_MODES as string[]).includes(raw ?? '') ? (raw as ColorMode) : 'dark';
|
||||
}
|
||||
|
||||
export interface ExifDisplayYaml {
|
||||
/** Camera body, lens, focal length. */
|
||||
camera?: boolean;
|
||||
/** Aperture, shutter speed, ISO. */
|
||||
settings?: boolean;
|
||||
/** City and country. */
|
||||
location?: boolean;
|
||||
/** The Immich asset description. */
|
||||
caption?: boolean;
|
||||
}
|
||||
|
||||
/** The resolved form: every group decided, plus whether the grid hovers at all. */
|
||||
export interface ExifDisplayConfig extends Required<ExifDisplayYaml> {
|
||||
/** Whether the photo grid shows its summary overlay on hover. */
|
||||
onHover: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the `exif:` block over the older `exifOnHover` switch.
|
||||
*
|
||||
* `exifOnHover: false` used to mean "no technical EXIF anywhere" — it gated the
|
||||
* grid overlay *and* every technical field in the lightbox panel — while the
|
||||
* Immich description was served regardless, as editorial caption. That left the
|
||||
* one field holding private notes as the only one nobody could switch off
|
||||
* (#506). The description is now a group like any other, and the old switch
|
||||
* survives as the default the explicit groups are merged over, so both existing
|
||||
* spellings keep their current meaning.
|
||||
*/
|
||||
export function resolveExifDisplay(
|
||||
raw?: ExifDisplayYaml,
|
||||
exifOnHover?: boolean,
|
||||
): ExifDisplayConfig {
|
||||
const onHover = exifOnHover !== false;
|
||||
// What `exifOnHover: false` implied for the technical groups.
|
||||
const technical = onHover;
|
||||
|
||||
return {
|
||||
camera: raw?.camera ?? technical,
|
||||
settings: raw?.settings ?? technical,
|
||||
location: raw?.location ?? technical,
|
||||
// Captions were never covered by the old switch, so their default does not
|
||||
// follow it: a site that only ever set `exifOnHover: false` keeps showing
|
||||
// them until it says otherwise.
|
||||
caption: raw?.caption ?? true,
|
||||
onHover,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the lightbox info panel has anything left to show. When it does not,
|
||||
* the `i` key and its button are withdrawn rather than opening an empty panel.
|
||||
*/
|
||||
export function hasExifPanelContent(exif: ExifDisplayConfig): boolean {
|
||||
return exif.camera || exif.settings || exif.location || exif.caption;
|
||||
}
|
||||
|
||||
/** Opacity used when `watermark.opacity` is unset. Matches the documented example. */
|
||||
export const WATERMARK_DEFAULT_OPACITY = 0.5;
|
||||
|
||||
/**
|
||||
* Normalise `watermark.opacity` to a CSS opacity (0–1).
|
||||
*
|
||||
* The documented unit is a fraction (`opacity: 0.5`), and the admin slider
|
||||
* writes one — but the lightbox used to divide by 100 regardless, so a
|
||||
* configured 0.9 rendered as 0.009 and was invisible. People worked around that
|
||||
* by writing `90`, which the admin panel then displayed as "9000%" (#508).
|
||||
*
|
||||
* Both readings are therefore accepted, and the ambiguity is smaller than it
|
||||
* looks: a fraction never exceeds 1, and a percentage below 1 would be an
|
||||
* invisible watermark nobody configures on purpose. Anything above 1 is read as
|
||||
* a percentage, so those workarounds keep working; the admin panel writes the
|
||||
* fraction back on the next save.
|
||||
*/
|
||||
export function resolveWatermarkOpacity(raw?: number): number {
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) return WATERMARK_DEFAULT_OPACITY;
|
||||
const fraction = raw > 1 ? raw / 100 : raw;
|
||||
return Math.min(1, Math.max(0, fraction));
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
|
||||
Reference in New Issue
Block a user