experimental: portfolio features (justified layout, unlisted pages, captions, per-album grid, focal point, nav links, cover hero) (#431)

## Was ist das?

Sieben neue Portfolio-Features, alle als **experimental** gekennzeichnet
— in YAML-Kommentaren, Admin-Panel-Labels und Commit-Scopes, damit sie
einzeln nachvollziehbar und revertierbar bleiben.

## Features (je ein Commit)

| Commit | Feature |
| --- | --- |
| `justified rows gallery layout` | Neues Grid-Layout `justified`:
zeilenbasiert, gleiche Höhen, volle Breite — Flexbox ohne JS-Messung,
letzte Zeile bleibt natürlich klein |
| `hide subpages from navigation` | `hidden: true`: Seite aus
Nav/Homepage raus, per Direktlink erreichbar — im Gegensatz zu `enabled:
false` (404) |
| `lightbox captions` | Immich-Asset-Description als Bildunterschrift im
Info-Panel; EXIF-Route liefert bei `exifOnHover: false` jetzt nur noch
die Caption statt 403 |
| `per-album grid overrides` | `grid:` pro Album-Eintrag, Präzedenz
global < Subpage < Album |
| `per-album cover focal point` | `coverPosition:` → `object-position`
auf Cover-Karten, strikt validiert |
| `external links in header navigation` | `navLinks:` in settings.yaml,
nur http(s), sanitisiert |
| `cover splash hero style` | `heroStyle: cover`: Fullscreen-Splash mit
Enter-Link ins Portfolio |

Alle Optionen sind im Admin-Panel editierbar (PageBuilder /
SettingsEditor).

## Tests

- 356 Unit-Tests grün (13 neue: deriveGallery hidden/grid/coverPosition,
sanitizeNavLinks, EXIF-Route-Captions, justified-Layout-Validierung)
- `npx tsc --noEmit`: 0 Fehler
- `npm run build`: sauber
- Justified-Zeilenmathematik und Cover-Splash per Browser-Harness
visuell/numerisch verifiziert (volle Zeilen enden exakt an der
Containerkante, letzte Zeile bleibt natürlich)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-08-14 17:56:22 +02:00
committed by GitHub
parent 2fe1157a5d
commit 84c7eeef40
24 changed files with 916 additions and 84 deletions

View File

@@ -76,6 +76,32 @@ export function buildSubpageGrid(raw?: {
};
}
/**
* EXPERIMENTAL: keep only nav links that are safe to render as header <a>
* tags. Non-http(s) schemes (javascript:, data:) and incomplete entries are
* dropped with a warning rather than throwing — a bad external link should
* not take the site down.
*/
export function sanitizeNavLinks(
raw?: Array<{ label?: string; url?: string }>,
): Array<{ label: string; url: string }> {
if (!raw) return [];
const links: Array<{ label: string; url: string }> = [];
for (const entry of raw) {
const label = entry.label?.trim();
const url = entry.url?.trim();
if (!label || !url || !/^https?:\/\//i.test(url)) {
console.warn(
`[Folio] settings.yaml navLinks: dropping entry ${JSON.stringify(entry)} — ` +
'label and an http(s) url are required.',
);
continue;
}
links.push({ label, url });
}
return links;
}
/** The parts of AppConfig that come from gallery.yaml. */
export interface GalleryDerivation {
albums: string[];
@@ -87,6 +113,8 @@ export interface GalleryDerivation {
albumHeroImages: Record<string, string>;
albumSortModes: Record<string, AlbumSortMode>;
albumManualOrders: Record<string, string[]>;
albumGrids: Record<string, Partial<GridConfig>>;
albumCoverPositions: Record<string, string>;
}
/**
@@ -108,6 +136,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumHeroImages: Record<string, string> = {};
const albumSortModes: Record<string, AlbumSortMode> = {};
const albumManualOrders: Record<string, string[]> = {};
const albumGrids: Record<string, Partial<GridConfig>> = {};
const albumCoverPositions: Record<string, string> = {};
function processAlbumEntry(
entry: string | Record<string, string | AlbumEntryObject>,
@@ -162,6 +192,29 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
);
}
// EXPERIMENTAL: per-album grid override — reuses the subpage-grid
// normalisation so unknown layouts degrade identically.
if (value.grid) {
const built = buildSubpageGrid(value.grid);
if ('grid' in built) albumGrids[validatedUuid] = built.grid;
}
// EXPERIMENTAL: coverPosition lands in a style attribute, so only a
// strict CSS position grammar is allowed. Throw like `sort` does — a
// silent fallback would leave the owner wondering why nothing moves.
if (value.coverPosition != null) {
const pos = value.coverPosition.trim();
const keyword = /^(center|top|bottom|left|right)( (center|top|bottom|left|right))?$/;
const percent = /^\d{1,3}% \d{1,3}%$/;
if (!keyword.test(pos) && !percent.test(pos)) {
throw new Error(
`${context}: album ${validatedUuid} has an invalid coverPosition "${value.coverPosition}". ` +
`Use two percentages ("50% 25%") or CSS keywords ("top", "center bottom").`,
);
}
albumCoverPositions[validatedUuid] = pos;
}
}
return validatedUuid;
}
@@ -218,6 +271,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
...buildSubpageGrid(sp.grid),
};
});
@@ -244,6 +298,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
...buildSubpageGrid(sp.grid),
};
});
@@ -262,6 +317,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumHeroImages,
albumSortModes,
albumManualOrders,
albumGrids,
albumCoverPositions,
};
}
@@ -316,6 +373,9 @@ export function getConfig(): AppConfig {
albumHeroImages: {},
albumSortModes: {},
albumManualOrders: {},
albumGrids: {},
albumCoverPositions: {},
navLinks: [],
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
@@ -337,6 +397,8 @@ export function getConfig(): AppConfig {
albumHeroImages,
albumSortModes,
albumManualOrders,
albumGrids,
albumCoverPositions,
} = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
@@ -412,6 +474,9 @@ export function getConfig(): AppConfig {
albumHeroImages,
albumSortModes,
albumManualOrders,
albumGrids,
albumCoverPositions,
navLinks: sanitizeNavLinks(settings.navLinks),
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,