Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
723 lines
25 KiB
TypeScript
723 lines
25 KiB
TypeScript
import type { AlbumSortMode } from '../albumSort';
|
||
import type { LocationPrecision } from '../mapPrecision';
|
||
import type { NavEntry } from '../pages';
|
||
|
||
export interface SubpageSectionConfig {
|
||
title: string;
|
||
slug: string; // auto-derived from title, used as anchor
|
||
description?: string;
|
||
albumIds: string[];
|
||
}
|
||
|
||
export interface SubpageConfig {
|
||
name: string;
|
||
slug: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
albumIds: string[]; // flat list (all albums, incl. from sections)
|
||
sections?: SubpageSectionConfig[];
|
||
password?: string;
|
||
/** Photo grid for the albums on this page: global < subpage < album. */
|
||
grid?: Partial<GridConfig>;
|
||
/**
|
||
* The album-cover tiles on this page. Falls back to `grid` when unset, which
|
||
* is what a pre-#523 gallery.yaml renders with (#523).
|
||
*/
|
||
coverGrid?: Partial<GridConfig>;
|
||
proofing?: boolean;
|
||
essayFile?: string;
|
||
essayText?: string;
|
||
enabled?: boolean;
|
||
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||
hidden?: boolean;
|
||
}
|
||
|
||
export interface SubpageObjectValue {
|
||
title?: string;
|
||
subtitle?: string;
|
||
password?: string;
|
||
grid?: Partial<GridConfig>;
|
||
/** Album-cover tiles only; falls back to `grid` when unset (#523). */
|
||
coverGrid?: Partial<GridConfig>;
|
||
proofing?: boolean;
|
||
essayFile?: string;
|
||
essayText?: string;
|
||
enabled?: boolean;
|
||
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||
hidden?: boolean;
|
||
/** Map precision for every album here that does not set its own (#469). */
|
||
location?: string;
|
||
// Both album lists reuse AlbumEntryObject rather than inlining the shape:
|
||
// the inline copies had already drifted (they never gained `heroImage`), and
|
||
// this path is live for map-style subpages.
|
||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
sections?: Array<{
|
||
title: string;
|
||
description?: string;
|
||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
}>;
|
||
}
|
||
|
||
/** EXPERIMENTAL: external link shown in the header navigation. */
|
||
export interface NavLinkConfig {
|
||
label: string;
|
||
url: string;
|
||
}
|
||
|
||
export interface FooterConfig {
|
||
name?: string;
|
||
instagram?: string;
|
||
email?: string;
|
||
website?: string;
|
||
}
|
||
|
||
/**
|
||
* Whether a URL may go into an `href` taken from settings: http(s) only, so a
|
||
* `javascript:` or `data:` URL in hand-edited YAML never reaches a link.
|
||
*/
|
||
export function isHttpUrl(url: string): boolean {
|
||
return /^https?:\/\//i.test(url.trim());
|
||
}
|
||
|
||
export interface LegalConfig {
|
||
enabled: boolean;
|
||
/** Replaces the statute line under the title ("Angaben gemäß § 5 DDG"). */
|
||
heading?: string;
|
||
name: string;
|
||
address: string;
|
||
zipCity: string;
|
||
country: string;
|
||
email?: string;
|
||
phone?: string;
|
||
/** Second contact channel next to email, e.g. a contact form. http(s) only. */
|
||
contactUrl?: string;
|
||
contactLabel?: string;
|
||
taxId?: string;
|
||
vatId?: string;
|
||
extraInfo?: string;
|
||
}
|
||
|
||
/** The built-in contact form (`/contact`, #702). */
|
||
export interface ContactConfig {
|
||
enabled: boolean;
|
||
/**
|
||
* Where a "new message" notification is POSTed, e.g. an ntfy topic URL.
|
||
* `CONTACT_NOTIFY_URL` overrides the settings.yaml value. http(s) only.
|
||
*/
|
||
notifyUrl?: string;
|
||
/** Messages older than this are deleted. */
|
||
retentionDays: number;
|
||
}
|
||
|
||
export const CONTACT_RETENTION_DEFAULT = 90;
|
||
export const CONTACT_RETENTION_MAX = 365;
|
||
|
||
export interface ThemeConfig {
|
||
preset: string;
|
||
accent: string;
|
||
/**
|
||
* The accent for one colour mode, where a preset's single `accent` fails
|
||
* contrast against that mode's background (Minimal's black on its black dark
|
||
* mode, say). Only presets set these, and resolveTheme keeps them only while
|
||
* the owner uses the preset's own accent. See accentForMode().
|
||
*/
|
||
accentDark?: string;
|
||
accentLight?: string;
|
||
fonts: { heading: string; body: string; caption: string };
|
||
radius: number;
|
||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||
grain: boolean;
|
||
headerDot: boolean;
|
||
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic' | 'cover';
|
||
}
|
||
|
||
export interface GridConfig {
|
||
columns: number;
|
||
gap: number;
|
||
aspectRatio: string;
|
||
layout:
|
||
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
|
||
}
|
||
|
||
/**
|
||
* Bounds for the photo grid, shared by the site-wide and per-subpage inputs so
|
||
* the two forms cannot offer different ranges for the same setting.
|
||
*/
|
||
export const PHOTO_GRID_COLUMNS_MIN = 1;
|
||
export const PHOTO_GRID_COLUMNS_MAX = 6;
|
||
export const PHOTO_GRID_GAP_MAX = 48;
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* The overrides come from the subpage's own `coverGrid`, which sizes the cover
|
||
* tiles and nothing else — it used to be the same `grid` key the photos read,
|
||
* so a cover gap silently retuned every photo grid on the page (#523).
|
||
*
|
||
* `columns` still falls back to the global `settings.yaml` count, so a page
|
||
* that states nothing tiles its covers like its photos. `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;
|
||
}
|
||
|
||
/**
|
||
* `unknown` rather than `number`: this also guards the site-wide and
|
||
* per-subpage/album grid columns and gap (lib/config/index.ts), which a
|
||
* hand-edited gallery.yaml or settings.yaml can hand a string, `null`, or
|
||
* anything else. `typeof value !== 'number'` catches those before
|
||
* `Number.isFinite` gets a chance to — it would otherwise coerce nothing and
|
||
* just report `false` for a non-number, which happens to be what we want here
|
||
* too, but checking the type first makes that not an accident.
|
||
*/
|
||
export function clamp(value: unknown, 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 (typeof value !== 'number' || !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;
|
||
albums: string[];
|
||
standaloneAlbums: string[];
|
||
subpages: SubpageConfig[];
|
||
/**
|
||
* The header menu in gallery.yaml order: every subpage (hidden and disabled
|
||
* ones included — the menu filters) and every `- page:` reference (#722).
|
||
*/
|
||
nav: NavEntry[];
|
||
siteTitle: string;
|
||
siteSubtitle: string;
|
||
lang: string;
|
||
seo: {
|
||
title: string;
|
||
description: string;
|
||
titleTemplate: string;
|
||
noIndex: boolean;
|
||
noFollow: boolean;
|
||
/** Emitted as `license` in JSON-LD when set. */
|
||
license: string;
|
||
};
|
||
/**
|
||
* The site's own absolute URL, or null when nobody configured one. Sitemap,
|
||
* feed and JSON-LD need it and have no request context to derive it from.
|
||
*/
|
||
siteUrl: string | null;
|
||
/** Where `siteUrl` came from, so the admin panel can name it (#472). */
|
||
siteUrlSource: 'env' | 'settings' | 'none';
|
||
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;
|
||
contact: ContactConfig;
|
||
/** /privacy, shown while this is on and content/privacy.md has text (#699). */
|
||
privacy: { enabled: boolean };
|
||
map: boolean;
|
||
transitions: boolean;
|
||
/** Floating back-to-top arrow in the frontend. */
|
||
scrollToTop: boolean;
|
||
analytics: boolean;
|
||
proofing: {
|
||
enabled: boolean;
|
||
allowMailto: boolean;
|
||
/**
|
||
* Effective "email to photographer" recipient: `proofing.email` from
|
||
* settings when set, otherwise the footer contact email. Undefined means
|
||
* neither is configured, and the proofing dialog hides the email action
|
||
* rather than opening a compose with an empty To: (#736).
|
||
*/
|
||
email?: string;
|
||
};
|
||
aboutEnabled: boolean;
|
||
albumOverrides: Record<string, string>;
|
||
albumDescriptions: Record<string, string>;
|
||
albumPasswords: Record<string, string>;
|
||
albumHeroImages: Record<string, string>;
|
||
/** Per-album sort mode. Absent means `immich` — the album's own order. */
|
||
albumSortModes: Record<string, AlbumSortMode>;
|
||
/** Pinned asset UUIDs per album for `sort: manual`, in display order. */
|
||
albumManualOrders: Record<string, string[]>;
|
||
/** EXPERIMENTAL: per-album grid overrides, merged over the subpage/global grid. */
|
||
albumGrids: Record<string, Partial<GridConfig>>;
|
||
/** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */
|
||
albumCoverPositions: Record<string, string>;
|
||
/** Albums whose originals may be downloaded. Absent means no (#475). */
|
||
albumDownloads: Record<string, boolean>;
|
||
/** Per-album map precision. Absent means `exact` (#469). */
|
||
albumLocationPrecision: Record<string, LocationPrecision>;
|
||
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||
navLinks: NavLinkConfig[];
|
||
cacheTtl: number;
|
||
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
|
||
staleMaxAge: number;
|
||
/** Response-wait budget for Immich API calls. Does not cap image/video body transfer. */
|
||
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;
|
||
};
|
||
watermark?: {
|
||
enabled?: boolean;
|
||
text?: string;
|
||
opacity?: number;
|
||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||
};
|
||
}
|
||
|
||
export interface AlbumEntryObject {
|
||
/** Optional: an entry may carry only a sort mode and no title override. */
|
||
title?: string;
|
||
description?: string;
|
||
password?: string;
|
||
heroImage?: string;
|
||
/**
|
||
* Raw YAML shape — narrowed to `AlbumSortMode` in deriveGallery(), which is
|
||
* where an unknown value has to be rejected with a message a human can act on.
|
||
*/
|
||
sort?: string;
|
||
/** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */
|
||
assetOrder?: string[];
|
||
/** EXPERIMENTAL: per-album grid override, merged over the subpage/global grid */
|
||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
|
||
coverPosition?: string;
|
||
/** Offer the original file for download from the lightbox (#475). Off by default. */
|
||
download?: boolean;
|
||
/**
|
||
* How precisely this album's photographs may be placed on the map:
|
||
* `exact` (default), `city`, `country` or `hidden`. Narrowed to
|
||
* `LocationPrecision` in deriveGallery(), where a typo has to be rejected.
|
||
*/
|
||
location?: string;
|
||
}
|
||
|
||
/**
|
||
* A content page placed in the menu (#722): `- page: <slug>` among the
|
||
* subpages. The page itself lives in content/pages/<slug>.md.
|
||
*/
|
||
export interface PageRefYaml {
|
||
page: string;
|
||
}
|
||
|
||
/** Whether a raw `subpages:` list entry is a page reference rather than a subpage. */
|
||
export function isPageRef(entry: unknown): entry is PageRefYaml {
|
||
return (
|
||
typeof entry === 'object' &&
|
||
entry !== null &&
|
||
'page' in entry &&
|
||
typeof (entry as { page: unknown }).page === 'string'
|
||
);
|
||
}
|
||
|
||
export interface GalleryYaml {
|
||
hero?: string | string[];
|
||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
subpages?:
|
||
| Record<string, string[] | Array<string | Record<string, string>>>
|
||
| Array<GallerySubpageYaml | PageRefYaml>;
|
||
}
|
||
|
||
export interface GallerySubpageYaml {
|
||
name: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
sections?: Array<{
|
||
title: string;
|
||
description?: string;
|
||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||
}>;
|
||
password?: string;
|
||
proofing?: boolean;
|
||
essayFile?: string;
|
||
essayText?: string;
|
||
enabled?: boolean;
|
||
hidden?: boolean;
|
||
/** Map precision inherited by every album here (#469). */
|
||
location?: string;
|
||
grid?: {
|
||
columns?: number;
|
||
gap?: number;
|
||
aspectRatio?: string;
|
||
layout?: string;
|
||
};
|
||
/** Album covers only; falls back to `grid` when unset (#523). */
|
||
coverGrid?: {
|
||
columns?: number;
|
||
gap?: number;
|
||
aspectRatio?: string;
|
||
layout?: string;
|
||
};
|
||
}
|
||
|
||
export interface SettingsYaml {
|
||
title?: string;
|
||
subtitle?: string;
|
||
/** Absolute site URL, e.g. https://folio.example. `SITE_URL` overrides it. */
|
||
url?: string;
|
||
lang?: string;
|
||
seo?: {
|
||
title?: string;
|
||
description?: string;
|
||
titleTemplate?: string;
|
||
noIndex?: boolean;
|
||
noFollow?: boolean;
|
||
/** Licence URL or identifier for structured data, e.g. a CC link (#472). */
|
||
license?: string;
|
||
};
|
||
/** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */
|
||
mode?: ColorMode;
|
||
exifOnHover?: boolean;
|
||
exif?: ExifDisplayYaml;
|
||
map?: boolean;
|
||
transitions?: boolean;
|
||
scrollToTop?: boolean;
|
||
analytics?: boolean;
|
||
proofing?: {
|
||
enabled?: boolean;
|
||
allowMailto?: boolean;
|
||
/** Overrides the footer contact email as the proofing recipient. */
|
||
email?: string;
|
||
};
|
||
theme?:
|
||
| string
|
||
| {
|
||
preset?: string;
|
||
accent?: string;
|
||
fonts?: { heading?: string; body?: string; caption?: string };
|
||
radius?: number;
|
||
photoFrame?: string;
|
||
grain?: boolean;
|
||
headerDot?: boolean;
|
||
heroStyle?: string;
|
||
};
|
||
grid?: {
|
||
columns?: number;
|
||
gap?: number;
|
||
aspectRatio?: string;
|
||
layout?: string;
|
||
};
|
||
footer?: FooterConfig;
|
||
legal?: Partial<LegalConfig>;
|
||
contact?: { enabled?: boolean; notifyUrl?: string; retentionDays?: number };
|
||
privacy?: { enabled?: boolean };
|
||
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||
navLinks?: Array<{ label?: string; url?: string }>;
|
||
protection?: {
|
||
disableRightClick?: boolean;
|
||
disableImageDrag?: boolean;
|
||
};
|
||
/** Puts the whole public site behind one password. `SITE_PASSWORD` overrides it. */
|
||
sitePassword?: string;
|
||
watermark?: {
|
||
enabled?: boolean;
|
||
text?: string;
|
||
opacity?: number;
|
||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||
};
|
||
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));
|
||
}
|
||
|
||
/**
|
||
* URL slug for a display name.
|
||
*
|
||
* NFD + combining-mark removal folds Latin diacritics ("Café" -> "cafe"), but
|
||
* everything else keeps its letters: the separator class is \p{L}\p{N}, not
|
||
* [a-z0-9]. The ASCII-only version stripped CJK, Hangul and Cyrillic names down
|
||
* to an empty slug, which turned their links into "/" (#522). NFC at the end
|
||
* recomposes what NFD split — Hangul syllables decompose into jamo, and only
|
||
* the composed form matches what a browser sends back in the URL.
|
||
*
|
||
* Can still return '' for a name made purely of symbols or emoji; callers that
|
||
* need a routable slug must supply a fallback (see albumSlug).
|
||
*/
|
||
export function slugify(name: string): string {
|
||
return name
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.normalize('NFC');
|
||
}
|
||
|
||
/**
|
||
* Routable slug for an album. Falls back to the album id when the name has no
|
||
* letters or digits at all, so an emoji-only album is still reachable — and so
|
||
* two of them do not collide on the same empty slug.
|
||
*/
|
||
export function albumSlug(name: string, id: string): string {
|
||
return slugify(name) || id;
|
||
}
|
||
|
||
/**
|
||
* Groups of albums whose slug collides, among a set that will all be
|
||
* reachable under one URL prefix — the standalone albums, or one subpage's
|
||
* albums (top-level and sectioned together, since they share a route).
|
||
*
|
||
* Every consumer that resolves a slug — getAlbumBySlug, the sitemap, the page
|
||
* builder — takes the first match, so an unflagged collision makes the second
|
||
* album unreachable and has its settings (password, sort, grid…) resolved
|
||
* from the first instead (#632).
|
||
*
|
||
* An empty slug (a name with no letters or digits at all) is excluded:
|
||
* albumSlug() falls back to the album id for that case, which is unique by
|
||
* construction, so it can never actually collide.
|
||
*/
|
||
export function findAlbumSlugCollisions(
|
||
albums: Array<{ id: string; name: string }>,
|
||
): Array<{ slug: string; albums: Array<{ id: string; name: string }> }> {
|
||
const bySlug = new Map<string, Array<{ id: string; name: string }>>();
|
||
for (const album of albums) {
|
||
const slug = slugify(album.name);
|
||
if (!slug) continue;
|
||
const group = bySlug.get(slug) ?? [];
|
||
group.push(album);
|
||
bySlug.set(slug, group);
|
||
}
|
||
return Array.from(bySlug.entries())
|
||
.filter(([, group]) => group.length > 1)
|
||
.map(([slug, group]) => ({ slug, albums: group }));
|
||
}
|
||
|
||
/**
|
||
* Normalize a slug that came in from a URL before comparing it to a stored one.
|
||
*
|
||
* Two things can differ even when the slug is "the same":
|
||
*
|
||
* - Percent-encoding. A non-ASCII path segment reaches the route handler
|
||
* still encoded ("%E5%AE%B6..."), so comparing it raw against the stored
|
||
* slug never matches — the half of #522 that survived fixing slugify().
|
||
* - Unicode form. Slugs are stored NFC; a non-Latin slug typed or pasted on
|
||
* macOS can arrive NFD, and the two are different strings that render the
|
||
* same.
|
||
*
|
||
* ASCII slugs are unaffected by either step. No stored slug can contain a '%',
|
||
* since slugify() drops it, so decoding cannot collide with a real slug.
|
||
*/
|
||
export function normalizeSlug(slug: string): string {
|
||
let decoded = slug;
|
||
try {
|
||
decoded = decodeURIComponent(slug);
|
||
} catch {
|
||
// Malformed percent sequence — compare what we were actually given rather
|
||
// than throwing a 500 on a hand-mangled URL.
|
||
}
|
||
return decoded.normalize('NFC');
|
||
}
|
||
|
||
/**
|
||
* The albums the public site shows: the standalone ones and those of every
|
||
* subpage that is not offline. `albums` is the allowlist for loading an album
|
||
* and still holds the albums of a subpage taken offline with `enabled: false`
|
||
* (its configuration is kept), so it does not answer "is this published".
|
||
* Anything that lists albums or serves their photos outside a page route asks
|
||
* this instead.
|
||
*/
|
||
export function onlineAlbumIds(config: {
|
||
standaloneAlbums?: readonly string[];
|
||
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean }>;
|
||
}): Set<string> {
|
||
return new Set([
|
||
...(config.standaloneAlbums ?? []),
|
||
...config.subpages.filter((sp) => sp.enabled !== false).flatMap((sp) => sp.albumIds),
|
||
]);
|
||
}
|
||
|
||
/** A subpage that appears in listings: online and not `hidden`. */
|
||
export function isListedSubpage(sp: { enabled?: boolean; hidden?: boolean }): boolean {
|
||
return sp.enabled !== false && sp.hidden !== true;
|
||
}
|
||
|
||
/**
|
||
* The albums a public listing may name: the standalone ones and those of
|
||
* every subpage that is online and not `hidden`. Narrower than
|
||
* {@link onlineAlbumIds} — a hidden subpage is reachable by direct link only,
|
||
* so its photos are still served (publishedAssets asks `onlineAlbumIds`), but
|
||
* an overview such as the map must not advertise them. An album that a listed
|
||
* subpage or the standalone list also carries stays.
|
||
*/
|
||
export function listedAlbumIds(config: {
|
||
standaloneAlbums?: readonly string[];
|
||
subpages: ReadonlyArray<{ albumIds: readonly string[]; enabled?: boolean; hidden?: boolean }>;
|
||
}): Set<string> {
|
||
return new Set([
|
||
...(config.standaloneAlbums ?? []),
|
||
...config.subpages.filter(isListedSubpage).flatMap((sp) => sp.albumIds),
|
||
]);
|
||
}
|