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

@@ -34,7 +34,14 @@ export interface PhotoItem {
interface PhotoGridProps {
assets: PhotoItem[];
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
layout?:
| 'masonry'
| 'uniform'
| 'showcase'
| 'filmstrip'
| 'editorial-flow'
| 'essay'
| 'justified';
gridStyle?: React.CSSProperties;
watermark?: LightboxWatermark;
}
@@ -122,8 +129,21 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
return displayedAssets.map((asset, index) => {
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
// Justified rows: the flex sizing must sit on the outermost grid child,
// which is the FadeIn wrapper — not the .photo-grid__item inside it.
// Aspect ratio drives both grow factor and basis; ~3:2 fallback for
// assets without dimensions (e.g. videos) keeps the row math sane.
const justifiedAr = asset.aspectRatio || 1.5;
const justifiedStyle: React.CSSProperties | undefined =
layout === 'justified'
? {
flexGrow: justifiedAr,
flexBasis: `calc(var(--grid-row-height, 300px) * ${justifiedAr})`,
}
: undefined;
return (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0} style={justifiedStyle}>
<div
className={`photo-grid__item${
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''

View File

@@ -9,6 +9,8 @@ interface SubpageAlbum {
albumName: string;
assetCount: number;
albumThumbnailAssetId: string | null;
/** EXPERIMENTAL: focal point for the cover crop (CSS object-position) */
coverPosition?: string;
}
interface Placeholder {
@@ -59,6 +61,7 @@ function AlbumGrid({
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
loading="lazy"
{...(album.coverPosition ? { style: { objectPosition: album.coverPosition } } : {})}
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
/>
) : (

View File

@@ -182,6 +182,17 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const resolveLayout = (overrides?: Partial<GridConfig>) =>
overrides?.layout ?? config.grid.layout;
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
// the precedence is global < subpage < album.
const mergeAlbumGrid = (
albumId: string,
spGrid?: Partial<GridConfig>,
): Partial<GridConfig> | undefined => {
const albumGrid = config.albumGrids[albumId];
if (!albumGrid) return spGrid;
return { ...spGrid, ...albumGrid };
};
if (!path || path.length === 0) {
notFound();
}
@@ -225,8 +236,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
<AlbumDetailView
album={album}
images={images}
layout={resolveLayout(spGrid)}
gridStyle={buildGridStyle(spGrid)}
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
@@ -341,8 +352,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
<AlbumDetailView
album={album}
images={images}
layout={resolveLayout(result.subpage.grid)}
gridStyle={buildGridStyle(result.subpage.grid)}
layout={resolveLayout(mergeAlbumGrid(album.id, result.subpage.grid))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, result.subpage.grid))}
subtitle={result.subpage.subtitle}
backLinkHref="/"
backLinkLabel="Back to Gallery"
@@ -357,7 +368,14 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// Override albumThumbnailAssetId with the configured hero image (if any)
const albumsWithHero = albums.map((album) => {
const heroId = config.albumHeroImages[album.id];
return heroId ? { ...album, albumThumbnailAssetId: heroId } : album;
// EXPERIMENTAL: coverPosition rides along so the card crop can honour
// the configured focal point.
const coverPosition = config.albumCoverPositions[album.id];
return {
...album,
...(heroId ? { albumThumbnailAssetId: heroId } : {}),
...(coverPosition ? { coverPosition } : {}),
};
});
// Batch-fetch ThumbHash for album cover placeholders
@@ -412,8 +430,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
<AlbumDetailView
album={album}
images={images}
layout={resolveLayout()}
gridStyle={buildGridStyle()}
layout={resolveLayout(mergeAlbumGrid(album.id))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
backLinkHref="/"
backLinkLabel="Back to Gallery"
watermark={config.watermark}

View File

@@ -2220,7 +2220,9 @@ h3 .svg-icon {
.album-drawer {
width: 100%;
max-width: 620px;
/* Narrower than the subpage sheet (1100px) so the nested editor still reads
as a layer on top of it, but wide enough for the same two-column body. */
max-width: 960px;
max-height: 90vh;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
@@ -2255,8 +2257,9 @@ h3 .svg-icon {
gap: 0.5rem;
}
/* Single-column body — and the drawer's only scroll container. Nesting a second
scroller here is what used to clip the sort listbox popup. */
/* The drawer's only scroll container. Nesting a second scroller here is what
used to clip the sort listbox popup — the two columns inside must stay
overflow-free and scroll together. */
.album-drawer-body {
flex: 1;
min-height: 0;
@@ -2278,6 +2281,20 @@ h3 .svg-icon {
}
}
/* Full-screen on phones, mirroring the subpage sheet. */
@media (max-width: 560px) {
.album-drawer-overlay {
padding: 0;
}
.album-drawer {
max-width: none;
max-height: none;
height: 100dvh;
border-radius: 0;
border: none;
}
}
.album-drawer-body::-webkit-scrollbar {
width: 6px;
}
@@ -3470,7 +3487,9 @@ h3 .svg-icon {
border-radius: 4px;
}
/* ── Subpage Slide-Over Drawer Modal ──────────────────────────── */
/* ── Subpage Editor Sheet (centered two-column modal) ─────────── */
/* z-index 999 is load-bearing: the album detail modal (1000) and the
portalled Listbox popup (1100) must stack above this sheet. */
.subpage-drawer-backdrop {
position: fixed;
inset: 0;
@@ -3478,28 +3497,37 @@ h3 .svg-icon {
backdrop-filter: blur(6px);
z-index: 999;
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: fadeIn 0.2s ease-out;
}
.subpage-drawer-container {
width: 100%;
max-width: 620px;
height: 100vh;
max-width: 1100px;
max-height: 90vh;
background: var(--admin-bg-card, #181818);
border-left: 1px solid var(--admin-border, #333);
border: 1px solid var(--admin-border, #333);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
animation: slideInRight 0.25s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
/* No fill-mode: after the entry animation the computed transform must
return to none, so the container never becomes a containing block for
fixed-position descendants (Listbox popup measurement). */
animation: sheetIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideInRight {
@keyframes sheetIn {
from {
transform: translateX(100%);
opacity: 0;
transform: scale(0.96) translateY(10px);
}
to {
transform: translateX(0);
opacity: 1;
transform: scale(1) translateY(0);
}
}
@@ -3538,6 +3566,8 @@ h3 .svg-icon {
overflow-y: auto;
overscroll-behavior: contain;
flex: 1;
/* Required so the flex child scrolls under max-height instead of growing */
min-height: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
@@ -3552,6 +3582,71 @@ h3 .svg-icon {
justify-content: space-between;
}
/* ── Shared two-column layout for admin editor sheets ─────────── */
/* Used by the subpage editor and the album detail editor so both read as the
same surface. Columns always live inside their sheet's single body scroller
— never give a column its own overflow, a nested scroller clips the
portalled Listbox popup (see the .album-drawer-body note). */
.admin-sheet-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
/* The subpage editor is asymmetric on purpose: the left column is a settings
form with a natural maximum, the right one holds the album list and wants
every pixel it can get. */
.admin-sheet-columns--settings-aside {
grid-template-columns: minmax(380px, 460px) 1fr;
}
.admin-sheet-col {
display: flex;
flex-direction: column;
gap: 1.25rem;
min-width: 0;
}
.admin-sheet-col--divided {
border-left: 1px solid var(--admin-border);
padding-left: 2rem;
}
@media (max-width: 1024px) {
.admin-sheet-columns,
.admin-sheet-columns--settings-aside {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.admin-sheet-col--divided {
border-left: none;
padding-left: 0;
}
}
@media (max-width: 768px) {
.subpage-drawer-backdrop {
padding: 0;
}
.subpage-drawer-container {
max-width: none;
max-height: none;
height: 100dvh;
border-radius: 0;
border: none;
}
}
@media (prefers-reduced-motion: reduce) {
.subpage-drawer-container,
.subpage-drawer-backdrop {
animation: none;
}
}
/* Breadcrumb + title stack on the left of the drawer header. */
.subpage-drawer-title-group {
display: flex;

View File

@@ -170,6 +170,17 @@ export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2,
);
}
export function IconEyeOff({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" x2="22" y1="2" y2="22" />
</svg>
);
}
export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>

View File

@@ -43,6 +43,9 @@ import {
IconGripVertical,
IconHome,
IconImage,
IconBan,
IconEyeOff,
IconGlobe,
IconLock,
IconPencil,
IconPlus,
@@ -92,6 +95,10 @@ interface AlbumEntry {
sort?: AlbumSortMode;
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
assetOrder?: string[];
/** EXPERIMENTAL: per-album grid override (layout only in the UI for now) */
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
coverPosition?: string;
}
interface Section {
@@ -106,6 +113,8 @@ interface Subpage {
subtitle?: string;
password?: string;
enabled?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean;
essayText?: string;
essayFile?: string;
sections?: Section[];
@@ -153,7 +162,9 @@ function hasAlbumOptions(entry: AlbumEntry): boolean {
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length,
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition,
);
}
@@ -171,6 +182,8 @@ function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
};
});
}
@@ -192,6 +205,8 @@ function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
return { [entry.id]: val };
});
}
@@ -326,6 +341,12 @@ function SortableSubpageTile({
</span>
)}
{sp.hidden === true && sp.enabled !== false && (
<span className="subpage-badge-protected" title="Hidden from navigation, reachable by direct link">
Unlisted
</span>
)}
{sp.password && (
<span className="subpage-badge-protected" title="Password protected">
<IconLock size={12} /> Password
@@ -420,6 +441,22 @@ export default function PageBuilder() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dirty, saving, gallery]);
// ── Escape closes the subpage sheet — only when it is topmost ─
useEffect(() => {
if (expandedSubpage === null) return;
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
// Listbox preventDefaults its own Escape but does not stopPropagation —
// respect that so closing a popup never also closes the sheet.
if (e.defaultPrevented) return;
// A higher layer (album editor, pickers, order editor) owns the key.
if (editingAlbumAddress || pickerTarget || heroPickerTarget || orderEditorTarget) return;
setExpandedSubpage(null);
}
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [expandedSubpage, editingAlbumAddress, pickerTarget, heroPickerTarget, orderEditorTarget]);
// ── Unsaved changes guard ────────────────────────────────────
useEffect(() => {
function handleBeforeUnload(e: BeforeUnloadEvent) {
@@ -470,6 +507,7 @@ export default function PageBuilder() {
subtitle: sp.subtitle as string | undefined,
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
@@ -494,6 +532,7 @@ export default function PageBuilder() {
subtitle: sp.subtitle as string | undefined,
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(
@@ -533,6 +572,7 @@ export default function PageBuilder() {
if (sp.subtitle) entry.subtitle = sp.subtitle;
if (sp.password) entry.password = sp.password;
if (sp.enabled === false) entry.enabled = false;
if (sp.hidden === true) entry.hidden = true;
if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
@@ -1175,7 +1215,8 @@ export default function PageBuilder() {
)}
</div>
) : (
<>
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
<div className="admin-sheet-col">
<div className="subpage-drawer-section">
<div className="admin-field">
<label>Page Name (URL Identifier)</label>
@@ -1215,9 +1256,74 @@ export default function PageBuilder() {
/>
</div>
</div>
<div className="admin-field">
<label>Password Protection (optional)</label>
<div className="password-input-wrapper">
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Visibility &amp; Access</label>
{/*
One field, three states — enabled/hidden are two YAML flags,
but for the owner it is a single question: how visible is
this page? Splitting it across two toggles produced
contradictory copy ("Disabled (Hidden)" vs "Unlisted").
*/}
{(
[
{
key: 'published',
active: sp.enabled !== false && sp.hidden !== true,
icon: <IconGlobe size={15} />,
iconColor: '#4ade80',
title: 'Published',
desc: 'Shown in the header menu and homepage lists, reachable via URL',
patch: { enabled: true, hidden: false },
},
{
key: 'unlisted',
active: sp.enabled !== false && sp.hidden === true,
icon: <IconEyeOff size={15} />,
iconColor: '#fbbf24',
title: 'Unlisted (Experimental)',
desc: 'Not shown in menus or on the homepage, but reachable via direct link',
patch: { enabled: true, hidden: true },
},
{
key: 'disabled',
active: sp.enabled === false,
icon: <IconBan size={15} />,
iconColor: '#f87171',
title: 'Disabled',
desc: 'Completely offline — returns 404 even when accessed directly',
patch: { enabled: false, hidden: false },
},
] as const
).map((opt) => (
<button
key={opt.key}
type="button"
className={`admin-toggle-card ${opt.active ? 'active' : ''}`}
onClick={() => updateSubpage(spIndex, opt.patch)}
aria-pressed={opt.active}
style={{ padding: '10px 14px', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderRadius: '8px', cursor: 'pointer', marginBottom: '6px' }}
>
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
<span className="toggle-card-title" style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: opt.iconColor, display: 'inline-flex' }} aria-hidden="true">
{opt.icon}
</span>
{opt.title}
</span>
<span className="toggle-card-desc" style={{ fontSize: '0.8rem', display: 'block', opacity: 0.75 }}>
{opt.desc}
</span>
</div>
<div className={`switch-toggle ${opt.active ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
))}
{/* Password lives in the same group: it is the access half
of "who gets to see this page". Applies to Published and
Unlisted; a Disabled page 404s before the gate. */}
<div className="password-input-wrapper" style={{ marginTop: '6px' }}>
<span className="password-icon"><IconLock size={12} /></span>
<input
type="password"
@@ -1225,40 +1331,11 @@ export default function PageBuilder() {
onChange={(e) =>
updateSubpage(spIndex, { password: e.target.value || undefined })
}
placeholder="Leave empty for public access"
placeholder="Password protection (optional) — leave empty for public access"
disabled={sp.enabled === false}
/>
</div>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Page Visibility Status</label>
<button
type="button"
className={`admin-toggle-card ${sp.enabled !== false ? 'active' : ''}`}
onClick={() => updateSubpage(spIndex, { enabled: sp.enabled === false })}
style={{ padding: '10px 14px', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderRadius: '8px', cursor: 'pointer' }}
>
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
<span className="toggle-card-title" style={{ fontWeight: 600 }}>
<span
className={`page-status-dot ${sp.enabled !== false ? 'is-on' : 'is-off'}`}
aria-hidden="true"
/>
{sp.enabled !== false
? 'Page Active (Published)'
: 'Page Disabled (Hidden)'}
</span>
<span className="toggle-card-desc" style={{ fontSize: '0.8rem', display: 'block', opacity: 0.75 }}>
{sp.enabled !== false
? 'Visible in header menu and reachable via URL'
: 'Hidden from navigation menu. Returns 404 if accessed directly.'}
</span>
</div>
<div className={`switch-toggle ${sp.enabled !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Page Layout Style</label>
<select
@@ -1313,8 +1390,9 @@ export default function PageBuilder() {
</div>
)}
<div className="settings-section-divider" />
</div>
<div className="admin-sheet-col admin-sheet-col--divided">
{/* Albums (if no sections) with DnD */}
{(!sp.sections || sp.sections.length === 0) && (
<div className="subpage-albums">
@@ -1437,7 +1515,8 @@ export default function PageBuilder() {
</button>
)}
</div>
</>
</div>
</div>
)}
</div>
@@ -1503,6 +1582,8 @@ export default function PageBuilder() {
form already shows (password state, custom hero) is not
repeated as a read-only stat. */}
<div className="album-drawer-body">
<div className="admin-sheet-columns">
<div className="admin-sheet-col">
<div className="modal-cover-container">
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
@@ -1551,6 +1632,40 @@ export default function PageBuilder() {
</div>
</div>
</div>
<div className="admin-sheet-col admin-sheet-col--divided">
<div className="admin-field">
<label>Layout override (Experimental)</label>
<select
value={album.grid?.layout || ''}
onChange={(e) => {
const layout = e.target.value || undefined;
// Only the layout is exposed here; drop the grid object
// entirely when it goes back to "inherit" so the YAML
// stays clean.
onUpdate({ grid: layout ? { ...(album.grid || {}), layout } : undefined });
}}
>
<option value="">Inherit from page / global settings</option>
<option value="masonry">Masonry</option>
<option value="uniform">Uniform Grid</option>
<option value="showcase">Showcase</option>
<option value="filmstrip">Filmstrip</option>
<option value="editorial-flow">Editorial Flow</option>
<option value="justified">Justified (Experimental)</option>
</select>
</div>
<div className="admin-field">
<label>Cover focal point (Experimental)</label>
<input
value={album.coverPosition || ''}
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
/>
</div>
{/* Hero Image Selection */}
<div className="admin-field">
<label>Custom Hero Image</label>
@@ -1627,6 +1742,8 @@ export default function PageBuilder() {
</div>
)}
</div>
</div>
</div>
</div>
<div className="album-drawer-footer">
<button

View File

@@ -35,6 +35,8 @@ interface Settings {
email?: string;
website?: string;
};
/** EXPERIMENTAL: external links appended to the header navigation */
navLinks?: Array<{ label?: string; url?: string }>;
legal?: {
enabled?: boolean;
name?: string;
@@ -71,9 +73,9 @@ interface Settings {
}
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
/**
@@ -760,6 +762,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: { label: 'Cover (Experimental)', desc: 'Fullscreen splash with a single Enter link' },
}[s] || { label: s, desc: '' };
const isActive = (settings.theme?.heroStyle || 'split') === s;
@@ -888,6 +891,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles' },
filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes' },
justified: { label: 'Justified (Experimental)', desc: 'Equal-height rows that fill the full width' },
}[l] || { label: l, desc: '' };
const isActive = (settings.grid?.layout || 'masonry') === l;
@@ -930,6 +934,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile" /></div>
</div>
)}
{l === 'justified' && (
<div className="demo-editorial-flow">
<div className="demo-row"><div className="demo-tile wide" /><div className="demo-tile" /></div>
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile wide" /></div>
</div>
)}
</div>
</div>
<div className="preset-card-info">
@@ -1050,6 +1060,59 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
/>
</div>
</div>
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
<h3><Icons.IconLink size={18} /> Header Navigation Links (Experimental)</h3>
<p className="settings-section-sub">External links shown after your pages in the header menu. Only http(s) URLs are allowed; they open in a new tab.</p>
</div>
{(settings.navLinks || []).map((link, i) => (
<div className="admin-field-row" key={i}>
<div className="admin-field">
<label>Label</label>
<input
value={link.label || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
next[i] = { ...next[i], label: e.target.value };
update('navLinks', next);
}}
placeholder="Shop"
/>
</div>
<div className="admin-field">
<label>URL</label>
<input
value={link.url || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
next[i] = { ...next[i], url: e.target.value };
update('navLinks', next);
}}
placeholder="https://shop.example.com"
/>
</div>
<button
type="button"
className="admin-btn admin-btn-sm"
style={{ alignSelf: 'flex-end' }}
onClick={() => {
const next = (settings.navLinks || []).filter((_, j) => j !== i);
update('navLinks', next.length > 0 ? next : undefined);
}}
title="Remove link"
>
Remove
</button>
</div>
))}
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
>
+ Add external link
</button>
</div>
)}

View File

@@ -14,10 +14,10 @@ import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const config = getConfig();
// Security: If EXIF is globally disabled, do not serve it via API either
if (!config.exifOnHover) {
return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 });
}
// exifOnHover only gates the *technical* fields below. The asset description
// (EXPERIMENTAL caption) is editorial content, so the route stays reachable
// and serves captions even when EXIF display is disabled.
const exifEnabled = config.exifOnHover;
// ── Rate limiting ──────────────────────────────────
const ip = getClientIp(request);
@@ -64,17 +64,23 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const exif = asset.exifInfo;
const caption = exif.description?.trim() || undefined;
return NextResponse.json(
{
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
city: exif.city,
country: exif.country,
...(exifEnabled
? {
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
city: exif.city,
country: exif.country,
}
: {}),
...(caption ? { description: caption } : {}),
},
{
headers: {

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* EXPERIMENTAL captions (L10): the route serves the Immich asset description
* as a caption. Captions are editorial, not technical — so exifOnHover: false
* must strip the technical fields but still serve the caption, instead of the
* old blanket 403.
*/
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(),
}));
vi.mock('@/lib/immich', () => ({
immich: { getAssetInfo: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/tokens', () => ({
decodeAssetId: vi.fn(() => 'asset-uuid'),
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
import { GET } from '../[id]/route';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import { NextRequest } from 'next/server';
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
const mockAssetInfo = immich.getAssetInfo as unknown as ReturnType<typeof vi.fn>;
const EXIF_ASSET = {
exifInfo: {
make: 'Fujifilm',
model: 'X-T5',
lensModel: 'XF 35mm',
focalLength: 35,
fNumber: 2,
exposureTime: '1/250',
iso: 200,
city: 'Vienna',
country: 'Austria',
description: ' A quiet morning at the Danube. ',
},
};
function makeRequest() {
return new NextRequest('http://localhost/api/exif/token123');
}
const params = { params: Promise.resolve({ id: 'token123' }) };
beforeEach(() => {
vi.clearAllMocks();
mockAssetInfo.mockResolvedValue(EXIF_ASSET);
});
describe('GET /api/exif/[id] captions', () => {
it('includes the trimmed description alongside technical fields', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.model).toBe('X-T5');
expect(body.description).toBe('A quiet morning at the Danube.');
});
it('serves only the caption when exifOnHover is disabled', async () => {
mockConfig.mockReturnValue({ exifOnHover: false, rateLimitRpm: 120 });
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.description).toBe('A quiet morning at the Danube.');
expect(body).not.toHaveProperty('model');
expect(body).not.toHaveProperty('iso');
});
it('omits the description entirely when the asset has none', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, description: null },
});
const res = await GET(makeRequest(), params);
const body = await res.json();
expect(body).not.toHaveProperty('description');
});
});

View File

@@ -244,6 +244,76 @@
overflow: hidden;
}
/* ── Cover / Splash hero (EXPERIMENTAL) ───────────── */
/* Fullscreen welcome page: one image, the title, a single Enter link. */
.hero--cover {
position: relative;
min-height: calc(100vh - 56px);
min-height: calc(100dvh - 56px);
overflow: hidden;
}
.hero--cover > span,
.hero--cover > img {
position: absolute !important;
inset: 0;
width: 100% !important;
height: 100% !important;
object-fit: cover;
}
.hero__cover-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 64px 40px;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.1) 75%);
color: #fff;
}
.hero--cover .hero__title,
.hero--cover .hero__subtitle {
color: #fff;
}
.hero__cover-enter {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 36px;
padding: 12px 32px;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: var(--radius-lg, 4px);
color: #fff;
font-family: var(--font-caption);
font-size: 0.85rem;
letter-spacing: 0.18em;
text-transform: uppercase;
text-decoration: none;
transition:
background var(--transition-normal),
color var(--transition-normal);
}
.hero__cover-enter:hover,
.hero__cover-enter:focus-visible {
background: #fff;
color: #000;
}
.hero__cover-enter-arrow {
transition: transform var(--transition-normal);
}
.hero__cover-enter:hover .hero__cover-enter-arrow {
transform: translateX(4px);
}
/* HeroCarousel images fill the entire container */
.hero--fullbleed > span,
.hero--fullbleed > img {
@@ -1084,6 +1154,55 @@
}
}
/* ── Justified Rows Layout (EXPERIMENTAL) ───────── */
/* Row-based justified layout: every row fills the container width, all
images in a row share the same height, aspect ratios stay intact.
Flex sizing (grow = aspect ratio) lives inline on the .fade-in wrapper. */
.photo-grid--justified {
display: flex;
flex-wrap: wrap;
gap: var(--grid-gap, 12px);
column-count: unset;
/* More columns → shorter rows, mirroring the density of the other layouts */
--grid-row-height: calc(900px / var(--grid-columns, 3));
}
/* Filler that soaks up leftover space so the last row keeps natural sizes
instead of stretching a lone image to full width */
.photo-grid--justified::after {
content: '';
flex-grow: 999999;
min-width: 0;
}
.photo-grid--justified > .fade-in {
height: var(--grid-row-height);
min-width: 0;
}
.photo-grid--justified .photo-grid__item {
height: 100%;
width: 100%;
margin-bottom: 0;
/* The base .photo-grid__item rule forces aspect-ratio from the grid
setting. Combined with height: 100% that pins the item width to
height × ratio, so it never fills the flex-grown wrapper and rows end
ragged. The wrapper's flex sizing is the only geometry source here. */
aspect-ratio: unset;
}
@media (max-width: 1024px) {
.photo-grid--justified {
--grid-row-height: 240px;
}
}
@media (max-width: 640px) {
.photo-grid--justified {
--grid-row-height: 160px;
}
}
.photo-grid__item img {
width: 100%;
height: 100%;

View File

@@ -137,6 +137,38 @@ export default async function HomePage() {
const heroStyle = config.theme.heroStyle;
// ── Cover (EXPERIMENTAL): fullscreen splash + single "Enter" link ─
// Welcome-page splash: one fullbleed image, the site
// title, and one link into the portfolio (the first nav entry).
if (heroStyle === 'cover') {
const entries = heroNavEntries(subpages, albums);
const enterHref = entries[0]?.href ?? '/about';
return (
<div className="hero hero--cover">
<HeroCarousel images={heroData} />
<div className="hero__cover-overlay">
<FadeIn delay={0}>
<h1 className="hero__title">{config.siteTitle}</h1>
</FadeIn>
{config.siteSubtitle && (
<FadeIn delay={100}>
<p className="hero__subtitle">{config.siteSubtitle}</p>
</FadeIn>
)}
<FadeIn delay={250}>
<Link href={enterHref} className="hero__cover-enter">
Enter
<span className="hero__cover-enter-arrow" aria-hidden="true">
→
</span>
</Link>
</FadeIn>
</div>
</div>
);
}
// ── Typographic: no image, pure text ───────────────────────────
if (heroStyle === 'typographic') {
return (