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:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -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()