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:
@@ -34,7 +34,14 @@ export interface PhotoItem {
|
|||||||
|
|
||||||
interface PhotoGridProps {
|
interface PhotoGridProps {
|
||||||
assets: PhotoItem[];
|
assets: PhotoItem[];
|
||||||
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
|
layout?:
|
||||||
|
| 'masonry'
|
||||||
|
| 'uniform'
|
||||||
|
| 'showcase'
|
||||||
|
| 'filmstrip'
|
||||||
|
| 'editorial-flow'
|
||||||
|
| 'essay'
|
||||||
|
| 'justified';
|
||||||
gridStyle?: React.CSSProperties;
|
gridStyle?: React.CSSProperties;
|
||||||
watermark?: LightboxWatermark;
|
watermark?: LightboxWatermark;
|
||||||
}
|
}
|
||||||
@@ -122,8 +129,21 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
|||||||
return displayedAssets.map((asset, index) => {
|
return displayedAssets.map((asset, index) => {
|
||||||
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
|
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 (
|
return (
|
||||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0} style={justifiedStyle}>
|
||||||
<div
|
<div
|
||||||
className={`photo-grid__item${
|
className={`photo-grid__item${
|
||||||
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface SubpageAlbum {
|
|||||||
albumName: string;
|
albumName: string;
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
albumThumbnailAssetId: string | null;
|
albumThumbnailAssetId: string | null;
|
||||||
|
/** EXPERIMENTAL: focal point for the cover crop (CSS object-position) */
|
||||||
|
coverPosition?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Placeholder {
|
interface Placeholder {
|
||||||
@@ -59,6 +61,7 @@ function AlbumGrid({
|
|||||||
fill
|
fill
|
||||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
{...(album.coverPosition ? { style: { objectPosition: album.coverPosition } } : {})}
|
||||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -182,6 +182,17 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
||||||
overrides?.layout ?? config.grid.layout;
|
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) {
|
if (!path || path.length === 0) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -225,8 +236,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
<AlbumDetailView
|
<AlbumDetailView
|
||||||
album={album}
|
album={album}
|
||||||
images={images}
|
images={images}
|
||||||
layout={resolveLayout(spGrid)}
|
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
|
||||||
gridStyle={buildGridStyle(spGrid)}
|
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
|
||||||
backLinkHref={`/${subpageSlug}`}
|
backLinkHref={`/${subpageSlug}`}
|
||||||
backLinkLabel={`Back to ${subpageName}`}
|
backLinkLabel={`Back to ${subpageName}`}
|
||||||
watermark={config.watermark}
|
watermark={config.watermark}
|
||||||
@@ -341,8 +352,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
<AlbumDetailView
|
<AlbumDetailView
|
||||||
album={album}
|
album={album}
|
||||||
images={images}
|
images={images}
|
||||||
layout={resolveLayout(result.subpage.grid)}
|
layout={resolveLayout(mergeAlbumGrid(album.id, result.subpage.grid))}
|
||||||
gridStyle={buildGridStyle(result.subpage.grid)}
|
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, result.subpage.grid))}
|
||||||
subtitle={result.subpage.subtitle}
|
subtitle={result.subpage.subtitle}
|
||||||
backLinkHref="/"
|
backLinkHref="/"
|
||||||
backLinkLabel="Back to Gallery"
|
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)
|
// Override albumThumbnailAssetId with the configured hero image (if any)
|
||||||
const albumsWithHero = albums.map((album) => {
|
const albumsWithHero = albums.map((album) => {
|
||||||
const heroId = config.albumHeroImages[album.id];
|
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
|
// Batch-fetch ThumbHash for album cover placeholders
|
||||||
@@ -412,8 +430,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
<AlbumDetailView
|
<AlbumDetailView
|
||||||
album={album}
|
album={album}
|
||||||
images={images}
|
images={images}
|
||||||
layout={resolveLayout()}
|
layout={resolveLayout(mergeAlbumGrid(album.id))}
|
||||||
gridStyle={buildGridStyle()}
|
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
|
||||||
backLinkHref="/"
|
backLinkHref="/"
|
||||||
backLinkLabel="Back to Gallery"
|
backLinkLabel="Back to Gallery"
|
||||||
watermark={config.watermark}
|
watermark={config.watermark}
|
||||||
|
|||||||
@@ -2220,7 +2220,9 @@ h3 .svg-icon {
|
|||||||
|
|
||||||
.album-drawer {
|
.album-drawer {
|
||||||
width: 100%;
|
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;
|
max-height: 90vh;
|
||||||
background: var(--admin-bg-raised);
|
background: var(--admin-bg-raised);
|
||||||
border: 1px solid var(--admin-border);
|
border: 1px solid var(--admin-border);
|
||||||
@@ -2255,8 +2257,9 @@ h3 .svg-icon {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Single-column body — and the drawer's only scroll container. Nesting a second
|
/* The drawer's only scroll container. Nesting a second scroller here is what
|
||||||
scroller here is what used to clip the sort listbox popup. */
|
used to clip the sort listbox popup — the two columns inside must stay
|
||||||
|
overflow-free and scroll together. */
|
||||||
.album-drawer-body {
|
.album-drawer-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.album-drawer-body::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
@@ -3470,7 +3487,9 @@ h3 .svg-icon {
|
|||||||
border-radius: 4px;
|
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 {
|
.subpage-drawer-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -3478,28 +3497,37 @@ h3 .svg-icon {
|
|||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subpage-drawer-container {
|
.subpage-drawer-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 620px;
|
max-width: 1100px;
|
||||||
height: 100vh;
|
max-height: 90vh;
|
||||||
background: var(--admin-bg-card, #181818);
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||||
animation: slideInRight 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
/* 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 {
|
from {
|
||||||
transform: translateX(100%);
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(0);
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3538,6 +3566,8 @@ h3 .svg-icon {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* Required so the flex child scrolls under max-height instead of growing */
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@@ -3552,6 +3582,71 @@ h3 .svg-icon {
|
|||||||
justify-content: space-between;
|
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. */
|
/* Breadcrumb + title stack on the left of the drawer header. */
|
||||||
.subpage-drawer-title-group {
|
.subpage-drawer-title-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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) {
|
export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ import {
|
|||||||
IconGripVertical,
|
IconGripVertical,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconImage,
|
IconImage,
|
||||||
|
IconBan,
|
||||||
|
IconEyeOff,
|
||||||
|
IconGlobe,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
@@ -92,6 +95,10 @@ interface AlbumEntry {
|
|||||||
sort?: AlbumSortMode;
|
sort?: AlbumSortMode;
|
||||||
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
|
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
|
||||||
assetOrder?: string[];
|
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 {
|
interface Section {
|
||||||
@@ -106,6 +113,8 @@ interface Subpage {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||||||
|
hidden?: boolean;
|
||||||
essayText?: string;
|
essayText?: string;
|
||||||
essayFile?: string;
|
essayFile?: string;
|
||||||
sections?: Section[];
|
sections?: Section[];
|
||||||
@@ -153,7 +162,9 @@ function hasAlbumOptions(entry: AlbumEntry): boolean {
|
|||||||
entry.password ||
|
entry.password ||
|
||||||
entry.heroImage ||
|
entry.heroImage ||
|
||||||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
|
(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,
|
heroImage: value.heroImage,
|
||||||
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
|
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
|
||||||
assetOrder: value.assetOrder,
|
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
|
// Persisted regardless of the mode, so manual → newest → manual does not
|
||||||
// throw away a hand-curated order.
|
// throw away a hand-curated order.
|
||||||
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
|
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 };
|
return { [entry.id]: val };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -326,6 +341,12 @@ function SortableSubpageTile({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sp.hidden === true && sp.enabled !== false && (
|
||||||
|
<span className="subpage-badge-protected" title="Hidden from navigation, reachable by direct link">
|
||||||
|
Unlisted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{sp.password && (
|
{sp.password && (
|
||||||
<span className="subpage-badge-protected" title="Password protected">
|
<span className="subpage-badge-protected" title="Password protected">
|
||||||
<IconLock size={12} /> Password
|
<IconLock size={12} /> Password
|
||||||
@@ -420,6 +441,22 @@ export default function PageBuilder() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dirty, saving, gallery]);
|
}, [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 ────────────────────────────────────
|
// ── Unsaved changes guard ────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
@@ -470,6 +507,7 @@ export default function PageBuilder() {
|
|||||||
subtitle: sp.subtitle as string | undefined,
|
subtitle: sp.subtitle as string | undefined,
|
||||||
password: sp.password as string | undefined,
|
password: sp.password as string | undefined,
|
||||||
enabled: sp.enabled !== false,
|
enabled: sp.enabled !== false,
|
||||||
|
hidden: sp.hidden === true,
|
||||||
essayText: sp.essayText as string | undefined,
|
essayText: sp.essayText as string | undefined,
|
||||||
essayFile: sp.essayFile as string | undefined,
|
essayFile: sp.essayFile as string | undefined,
|
||||||
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, 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,
|
subtitle: sp.subtitle as string | undefined,
|
||||||
password: sp.password as string | undefined,
|
password: sp.password as string | undefined,
|
||||||
enabled: sp.enabled !== false,
|
enabled: sp.enabled !== false,
|
||||||
|
hidden: sp.hidden === true,
|
||||||
essayText: sp.essayText as string | undefined,
|
essayText: sp.essayText as string | undefined,
|
||||||
essayFile: sp.essayFile as string | undefined,
|
essayFile: sp.essayFile as string | undefined,
|
||||||
albums: parseAlbumEntries(
|
albums: parseAlbumEntries(
|
||||||
@@ -533,6 +572,7 @@ export default function PageBuilder() {
|
|||||||
if (sp.subtitle) entry.subtitle = sp.subtitle;
|
if (sp.subtitle) entry.subtitle = sp.subtitle;
|
||||||
if (sp.password) entry.password = sp.password;
|
if (sp.password) entry.password = sp.password;
|
||||||
if (sp.enabled === false) entry.enabled = false;
|
if (sp.enabled === false) entry.enabled = false;
|
||||||
|
if (sp.hidden === true) entry.hidden = true;
|
||||||
if (sp.essayText) entry.essayText = sp.essayText;
|
if (sp.essayText) entry.essayText = sp.essayText;
|
||||||
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
||||||
if (sp.grid) entry.grid = sp.grid;
|
if (sp.grid) entry.grid = sp.grid;
|
||||||
@@ -1175,7 +1215,8 @@ export default function PageBuilder() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
|
||||||
|
<div className="admin-sheet-col">
|
||||||
<div className="subpage-drawer-section">
|
<div className="subpage-drawer-section">
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Page Name (URL Identifier)</label>
|
<label>Page Name (URL Identifier)</label>
|
||||||
@@ -1215,9 +1256,74 @@ export default function PageBuilder() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||||
<label>Password Protection (optional)</label>
|
<label>Visibility & Access</label>
|
||||||
<div className="password-input-wrapper">
|
{/*
|
||||||
|
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>
|
<span className="password-icon"><IconLock size={12} /></span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -1225,40 +1331,11 @@ export default function PageBuilder() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSubpage(spIndex, { password: e.target.value || undefined })
|
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>
|
</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' }}>
|
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||||
<label>Page Layout Style</label>
|
<label>Page Layout Style</label>
|
||||||
<select
|
<select
|
||||||
@@ -1313,8 +1390,9 @@ export default function PageBuilder() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="settings-section-divider" />
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-sheet-col admin-sheet-col--divided">
|
||||||
{/* Albums (if no sections) with DnD */}
|
{/* Albums (if no sections) with DnD */}
|
||||||
{(!sp.sections || sp.sections.length === 0) && (
|
{(!sp.sections || sp.sections.length === 0) && (
|
||||||
<div className="subpage-albums">
|
<div className="subpage-albums">
|
||||||
@@ -1437,7 +1515,8 @@ export default function PageBuilder() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1503,6 +1582,8 @@ export default function PageBuilder() {
|
|||||||
form already shows (password state, custom hero) is not
|
form already shows (password state, custom hero) is not
|
||||||
repeated as a read-only stat. */}
|
repeated as a read-only stat. */}
|
||||||
<div className="album-drawer-body">
|
<div className="album-drawer-body">
|
||||||
|
<div className="admin-sheet-columns">
|
||||||
|
<div className="admin-sheet-col">
|
||||||
<div className="modal-cover-container">
|
<div className="modal-cover-container">
|
||||||
{heroThumb ? (
|
{heroThumb ? (
|
||||||
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
|
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
|
||||||
@@ -1551,6 +1632,40 @@ export default function PageBuilder() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Hero Image Selection */}
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Custom Hero Image</label>
|
<label>Custom Hero Image</label>
|
||||||
@@ -1627,6 +1742,8 @@ export default function PageBuilder() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-drawer-footer">
|
<div className="album-drawer-footer">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ interface Settings {
|
|||||||
email?: string;
|
email?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
};
|
};
|
||||||
|
/** EXPERIMENTAL: external links appended to the header navigation */
|
||||||
|
navLinks?: Array<{ label?: string; url?: string }>;
|
||||||
legal?: {
|
legal?: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -71,9 +73,9 @@ interface Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
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 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'];
|
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' },
|
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
|
||||||
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
|
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
|
||||||
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
|
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
|
||||||
|
cover: { label: 'Cover (Experimental)', desc: 'Fullscreen splash with a single Enter link' },
|
||||||
}[s] || { label: s, desc: '' };
|
}[s] || { label: s, desc: '' };
|
||||||
const isActive = (settings.theme?.heroStyle || 'split') === s;
|
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' },
|
showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles' },
|
||||||
filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
|
filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
|
||||||
'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes' },
|
'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: '' };
|
}[l] || { label: l, desc: '' };
|
||||||
const isActive = (settings.grid?.layout || 'masonry') === l;
|
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 className="demo-row"><div className="demo-tile" /><div className="demo-tile" /></div>
|
||||||
</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>
|
</div>
|
||||||
<div className="preset-card-info">
|
<div className="preset-card-info">
|
||||||
@@ -1050,6 +1060,59 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit
|
|||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
// Security: If EXIF is globally disabled, do not serve it via API either
|
// exifOnHover only gates the *technical* fields below. The asset description
|
||||||
if (!config.exifOnHover) {
|
// (EXPERIMENTAL caption) is editorial content, so the route stays reachable
|
||||||
return NextResponse.json({ error: 'EXIF metadata is disabled' }, { status: 403 });
|
// and serves captions even when EXIF display is disabled.
|
||||||
}
|
const exifEnabled = config.exifOnHover;
|
||||||
|
|
||||||
// ── Rate limiting ──────────────────────────────────
|
// ── Rate limiting ──────────────────────────────────
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
@@ -64,17 +64,23 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exif = asset.exifInfo;
|
const exif = asset.exifInfo;
|
||||||
|
const caption = exif.description?.trim() || undefined;
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
make: exif.make,
|
...(exifEnabled
|
||||||
model: exif.model,
|
? {
|
||||||
lensModel: exif.lensModel,
|
make: exif.make,
|
||||||
focalLength: exif.focalLength,
|
model: exif.model,
|
||||||
fNumber: exif.fNumber,
|
lensModel: exif.lensModel,
|
||||||
exposureTime: exif.exposureTime,
|
focalLength: exif.focalLength,
|
||||||
iso: exif.iso,
|
fNumber: exif.fNumber,
|
||||||
city: exif.city,
|
exposureTime: exif.exposureTime,
|
||||||
country: exif.country,
|
iso: exif.iso,
|
||||||
|
city: exif.city,
|
||||||
|
country: exif.country,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(caption ? { description: caption } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
95
app/api/exif/__tests__/exif-route.test.ts
Normal file
95
app/api/exif/__tests__/exif-route.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
119
app/globals.css
119
app/globals.css
@@ -244,6 +244,76 @@
|
|||||||
overflow: hidden;
|
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 */
|
/* HeroCarousel images fill the entire container */
|
||||||
.hero--fullbleed > span,
|
.hero--fullbleed > span,
|
||||||
.hero--fullbleed > img {
|
.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 {
|
.photo-grid__item img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
32
app/page.tsx
32
app/page.tsx
@@ -137,6 +137,38 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
const heroStyle = config.theme.heroStyle;
|
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 ───────────────────────────
|
// ── Typographic: no image, pure text ───────────────────────────
|
||||||
if (heroStyle === 'typographic') {
|
if (heroStyle === 'typographic') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ interface FadeInProps {
|
|||||||
direction?: 'up' | 'none';
|
direction?: 'up' | 'none';
|
||||||
/** CSS class name for the wrapper */
|
/** CSS class name for the wrapper */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Extra inline styles for the wrapper (e.g. flex sizing in justified grids) */
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) {
|
export function FadeIn({ children, delay = 0, direction = 'up', className, style }: FadeInProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const reveal = useCallback(() => {
|
const reveal = useCallback(() => {
|
||||||
@@ -55,7 +57,7 @@ export function FadeIn({ children, delay = 0, direction = 'up', className }: Fad
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`fade-in ${direction === 'up' ? 'fade-in--up' : ''} ${className ?? ''}`}
|
className={`fade-in ${direction === 'up' ? 'fade-in--up' : ''} ${className ?? ''}`}
|
||||||
style={{ transitionDelay: `${delay}ms` }}
|
style={{ ...style, transitionDelay: `${delay}ms` }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -184,6 +184,17 @@
|
|||||||
animation: exif-slide-up var(--transition-normal) ease forwards;
|
animation: exif-slide-up var(--transition-normal) ease forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* EXPERIMENTAL: editorial caption (Immich asset description) above the tech rows */
|
||||||
|
.exifCaption {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
max-width: 280px;
|
||||||
|
font-family: var(--font-caption);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes exif-slide-up {
|
@keyframes exif-slide-up {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ export function Lightbox({
|
|||||||
</div>
|
</div>
|
||||||
) : exifData ? (
|
) : exifData ? (
|
||||||
<>
|
<>
|
||||||
|
{exifData.description && (
|
||||||
|
<p className={styles.exifCaption}>{exifData.description}</p>
|
||||||
|
)}
|
||||||
{exifData.model && (
|
{exifData.model && (
|
||||||
<div className={styles.exifRow}>
|
<div className={styles.exifRow}>
|
||||||
<span className={styles.exifLabel}>Camera</span>
|
<span className={styles.exifLabel}>Camera</span>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
import { listJournalEntries } from '@/lib/admin/journal-service';
|
import { listJournalEntries } from '@/lib/admin/journal-service';
|
||||||
|
|
||||||
export async function SubpageNav() {
|
export async function SubpageNav() {
|
||||||
@@ -13,6 +14,9 @@ export async function SubpageNav() {
|
|||||||
immich.getStandaloneAlbums(),
|
immich.getStandaloneAlbums(),
|
||||||
listJournalEntries().catch(() => []),
|
listJournalEntries().catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
// EXPERIMENTAL: external nav links from settings.yaml, appended after the
|
||||||
|
// internal entries. Sanitised to http(s) in getConfig().
|
||||||
|
const navLinks = getConfig().navLinks;
|
||||||
|
|
||||||
const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft);
|
const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft);
|
||||||
|
|
||||||
@@ -33,6 +37,17 @@ export async function SubpageNav() {
|
|||||||
{album.albumName}
|
{album.albumName}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.url}
|
||||||
|
href={link.url}
|
||||||
|
className="header__nav-link header__nav-link--external"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,17 @@ subpages:
|
|||||||
title: "My Creative Projects" # Optional: Header title, defaults to Navigation key ("Projects")
|
title: "My Creative Projects" # Optional: Header title, defaults to Navigation key ("Projects")
|
||||||
subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers
|
subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers
|
||||||
password: "your-password" # Optional protection
|
password: "your-password" # Optional protection
|
||||||
|
# hidden: true # EXPERIMENTAL: reachable by direct link, but not shown in navigation
|
||||||
albums:
|
albums:
|
||||||
- "album-uuid-5"
|
- "album-uuid-5"
|
||||||
- "album-uuid-6":
|
- "album-uuid-6":
|
||||||
title: "Private Project #7"
|
title: "Private Project #7"
|
||||||
description: "Shot on 35mm film in Vienna, spring 2025." # Optional: shown below album title
|
description: "Shot on 35mm film in Vienna, spring 2025." # Optional: shown below album title
|
||||||
password: "scrypt:..." # Recommended: secure hash (generated via logs)
|
password: "scrypt:..." # Recommended: secure hash (generated via logs)
|
||||||
|
# grid: # EXPERIMENTAL: per-album grid override (wins over page/global grid)
|
||||||
|
# layout: "justified"
|
||||||
|
# columns: 2
|
||||||
|
# coverPosition: "50% 25%" # EXPERIMENTAL: focal point for the cover crop ("top", "center bottom", "50% 25%")
|
||||||
|
|
||||||
# ── Subpage with Sections ──────────────────────────────────────────
|
# ── Subpage with Sections ──────────────────────────────────────────
|
||||||
# Use sections to group albums by location, theme, or any other logic.
|
# Use sections to group albums by location, theme, or any other logic.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ theme:
|
|||||||
# accent: "#e60012"
|
# accent: "#e60012"
|
||||||
# photoFrame: "passepartout" # none, passepartout, shadow
|
# photoFrame: "passepartout" # none, passepartout, shadow
|
||||||
# grain: true
|
# grain: true
|
||||||
# heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic
|
# heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic, cover (EXPERIMENTAL)
|
||||||
# Note: "mosaic" needs at least 3 hero images in gallery.yaml (optimal: 6)
|
# Note: "mosaic" needs at least 3 hero images in gallery.yaml (optimal: 6)
|
||||||
|
|
||||||
# ── Grid configuration ───────────────────────────────────────────
|
# ── Grid configuration ───────────────────────────────────────────
|
||||||
@@ -47,7 +47,16 @@ grid:
|
|||||||
columns: 3
|
columns: 3
|
||||||
gap: 12
|
gap: 12
|
||||||
aspectRatio: "1" # "1" (square), "3/2", "2/3", "16/9", "auto"
|
aspectRatio: "1" # "1" (square), "3/2", "2/3", "16/9", "auto"
|
||||||
layout: "masonry" # masonry, uniform, showcase, filmstrip, editorial-flow
|
layout: "masonry" # masonry, uniform, showcase, filmstrip, editorial-flow, justified (EXPERIMENTAL)
|
||||||
|
|
||||||
|
# ── Navigation ────────────────────────────────────────────────────
|
||||||
|
# EXPERIMENTAL: external links appended to the header navigation.
|
||||||
|
# Only http(s) URLs are allowed; they open in a new tab.
|
||||||
|
# navLinks:
|
||||||
|
# - label: "Shop"
|
||||||
|
# url: "https://shop.example.com"
|
||||||
|
# - label: "Instagram"
|
||||||
|
# url: "https://instagram.com/your-handle"
|
||||||
|
|
||||||
# ── Footer & Social ──────────────────────────────────────────────
|
# ── Footer & Social ──────────────────────────────────────────────
|
||||||
footer:
|
footer:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface ExifData {
|
|||||||
iso?: number | null;
|
iso?: number | null;
|
||||||
city?: string | null;
|
city?: string | null;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
|
/** EXPERIMENTAL: editorial caption from the Immich asset description */
|
||||||
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExif() {
|
export function useExif() {
|
||||||
|
|||||||
@@ -12,7 +12,38 @@ vi.mock('@/lib/env', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { slugify, buildSubpageGrid } from '@/lib/config';
|
import { slugify, buildSubpageGrid, sanitizeNavLinks } from '@/lib/config';
|
||||||
|
|
||||||
|
// EXPERIMENTAL: external navigation links — rendered as <a href> in the
|
||||||
|
// header, so only http(s) URLs may survive sanitisation.
|
||||||
|
describe('sanitizeNavLinks', () => {
|
||||||
|
it('keeps well-formed http(s) links', () => {
|
||||||
|
expect(
|
||||||
|
sanitizeNavLinks([
|
||||||
|
{ label: 'Shop', url: 'https://shop.example.com' },
|
||||||
|
{ label: 'Blog', url: 'http://blog.example.com/a?b=c' },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ label: 'Shop', url: 'https://shop.example.com' },
|
||||||
|
{ label: 'Blog', url: 'http://blog.example.com/a?b=c' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops non-http schemes and entries without label or url', () => {
|
||||||
|
expect(
|
||||||
|
sanitizeNavLinks([
|
||||||
|
{ label: 'Bad', url: 'javascript:alert(1)' },
|
||||||
|
{ label: 'AlsoBad', url: 'data:text/html,x' },
|
||||||
|
{ label: '', url: 'https://x.example' },
|
||||||
|
{ label: 'NoUrl', url: '' },
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for undefined input', () => {
|
||||||
|
expect(sanitizeNavLinks(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
it('converts a simple name to lowercase with hyphens', () => {
|
it('converts a simple name to lowercase with hyphens', () => {
|
||||||
@@ -79,6 +110,12 @@ describe('buildSubpageGrid', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves the experimental justified layout', () => {
|
||||||
|
expect(buildSubpageGrid({ layout: 'justified' })).toEqual({
|
||||||
|
grid: { layout: 'justified' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to masonry for an unknown layout', () => {
|
it('falls back to masonry for an unknown layout', () => {
|
||||||
expect(buildSubpageGrid({ layout: 'unknown-layout' })).toEqual({
|
expect(buildSubpageGrid({ layout: 'unknown-layout' })).toEqual({
|
||||||
grid: { layout: 'masonry' },
|
grid: { layout: 'masonry' },
|
||||||
|
|||||||
@@ -156,6 +156,66 @@ describe('deriveGallery accepts valid structures', () => {
|
|||||||
expect(result.albumManualOrders[A]).toEqual([B]);
|
expect(result.albumManualOrders[A]).toEqual([B]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// EXPERIMENTAL: hidden keeps a subpage reachable by direct link while
|
||||||
|
// removing it from navigation — distinct from enabled:false, which 404s.
|
||||||
|
it('derives the hidden flag on array-style subpages', () => {
|
||||||
|
const result = deriveGallery({
|
||||||
|
subpages: [{ name: 'Preview', hidden: true, albums: [A] }],
|
||||||
|
} as unknown as GalleryYaml);
|
||||||
|
|
||||||
|
expect(result.subpages[0].hidden).toBe(true);
|
||||||
|
expect(result.subpages[0].enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the hidden flag on map-style subpages, defaulting to falsy', () => {
|
||||||
|
const result = deriveGallery({
|
||||||
|
subpages: { Preview: { hidden: true, albums: [A] }, Open: [B] },
|
||||||
|
} as unknown as GalleryYaml);
|
||||||
|
|
||||||
|
const preview = result.subpages.find((sp) => sp.name === 'Preview');
|
||||||
|
const open = result.subpages.find((sp) => sp.name === 'Open');
|
||||||
|
expect(preview?.hidden).toBe(true);
|
||||||
|
expect(open?.hidden).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// EXPERIMENTAL: per-album grid overrides — merged over subpage/global grid.
|
||||||
|
it('collects per-album grid overrides, dropping unknown layouts to masonry', () => {
|
||||||
|
const result = deriveGallery({
|
||||||
|
albums: [
|
||||||
|
{ [A]: { title: 'Panoramas', grid: { layout: 'filmstrip', columns: 2 } } },
|
||||||
|
{ [B]: { grid: { layout: 'not-a-layout' } } },
|
||||||
|
],
|
||||||
|
} as unknown as GalleryYaml);
|
||||||
|
|
||||||
|
expect(result.albumGrids[A]).toEqual({ layout: 'filmstrip', columns: 2 });
|
||||||
|
expect(result.albumGrids[B]).toEqual({ layout: 'masonry' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves albumGrids empty for entries without a grid', () => {
|
||||||
|
const result = deriveGallery({ albums: [A] } as unknown as GalleryYaml);
|
||||||
|
expect(result.albumGrids).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
// EXPERIMENTAL: coverPosition feeds object-position on cover images, so it
|
||||||
|
// must be a strict CSS position value — anything else is a typo or an
|
||||||
|
// injection attempt and both should fail loudly.
|
||||||
|
it('collects a valid coverPosition', () => {
|
||||||
|
const result = deriveGallery({
|
||||||
|
albums: [{ [A]: { coverPosition: '50% 25%' } }, { [B]: { coverPosition: 'top' } }],
|
||||||
|
} as unknown as GalleryYaml);
|
||||||
|
|
||||||
|
expect(result.albumCoverPositions[A]).toBe('50% 25%');
|
||||||
|
expect(result.albumCoverPositions[B]).toBe('top');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed coverPosition, naming the album', () => {
|
||||||
|
expect(() =>
|
||||||
|
deriveGallery({
|
||||||
|
albums: [{ [A]: { coverPosition: 'javascript:alert(1)' } }],
|
||||||
|
} as unknown as GalleryYaml),
|
||||||
|
).toThrow(new RegExp(A));
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores an empty asset order rather than storing one', () => {
|
it('ignores an empty asset order rather than storing one', () => {
|
||||||
const result = deriveGallery({
|
const result = deriveGallery({
|
||||||
albums: [{ [A]: { sort: 'manual', assetOrder: [] } }],
|
albums: [{ [A]: { sort: 'manual', assetOrder: [] } }],
|
||||||
|
|||||||
@@ -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. */
|
/** The parts of AppConfig that come from gallery.yaml. */
|
||||||
export interface GalleryDerivation {
|
export interface GalleryDerivation {
|
||||||
albums: string[];
|
albums: string[];
|
||||||
@@ -87,6 +113,8 @@ export interface GalleryDerivation {
|
|||||||
albumHeroImages: Record<string, string>;
|
albumHeroImages: Record<string, string>;
|
||||||
albumSortModes: Record<string, AlbumSortMode>;
|
albumSortModes: Record<string, AlbumSortMode>;
|
||||||
albumManualOrders: Record<string, string[]>;
|
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 albumHeroImages: Record<string, string> = {};
|
||||||
const albumSortModes: Record<string, AlbumSortMode> = {};
|
const albumSortModes: Record<string, AlbumSortMode> = {};
|
||||||
const albumManualOrders: Record<string, string[]> = {};
|
const albumManualOrders: Record<string, string[]> = {};
|
||||||
|
const albumGrids: Record<string, Partial<GridConfig>> = {};
|
||||||
|
const albumCoverPositions: Record<string, string> = {};
|
||||||
|
|
||||||
function processAlbumEntry(
|
function processAlbumEntry(
|
||||||
entry: string | Record<string, string | AlbumEntryObject>,
|
entry: string | Record<string, string | AlbumEntryObject>,
|
||||||
@@ -162,6 +192,29 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
|
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;
|
return validatedUuid;
|
||||||
}
|
}
|
||||||
@@ -218,6 +271,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
essayFile: sp.essayFile,
|
essayFile: sp.essayFile,
|
||||||
essayText: sp.essayText,
|
essayText: sp.essayText,
|
||||||
enabled: sp.enabled !== false,
|
enabled: sp.enabled !== false,
|
||||||
|
hidden: sp.hidden === true,
|
||||||
...buildSubpageGrid(sp.grid),
|
...buildSubpageGrid(sp.grid),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -244,6 +298,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
essayFile: sp.essayFile,
|
essayFile: sp.essayFile,
|
||||||
essayText: sp.essayText,
|
essayText: sp.essayText,
|
||||||
enabled: sp.enabled !== false,
|
enabled: sp.enabled !== false,
|
||||||
|
hidden: sp.hidden === true,
|
||||||
...buildSubpageGrid(sp.grid),
|
...buildSubpageGrid(sp.grid),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -262,6 +317,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
albumHeroImages,
|
albumHeroImages,
|
||||||
albumSortModes,
|
albumSortModes,
|
||||||
albumManualOrders,
|
albumManualOrders,
|
||||||
|
albumGrids,
|
||||||
|
albumCoverPositions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +373,9 @@ export function getConfig(): AppConfig {
|
|||||||
albumHeroImages: {},
|
albumHeroImages: {},
|
||||||
albumSortModes: {},
|
albumSortModes: {},
|
||||||
albumManualOrders: {},
|
albumManualOrders: {},
|
||||||
|
albumGrids: {},
|
||||||
|
albumCoverPositions: {},
|
||||||
|
navLinks: [],
|
||||||
cacheTtl: env.CACHE_TTL * 1000,
|
cacheTtl: env.CACHE_TTL * 1000,
|
||||||
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
||||||
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
||||||
@@ -337,6 +397,8 @@ export function getConfig(): AppConfig {
|
|||||||
albumHeroImages,
|
albumHeroImages,
|
||||||
albumSortModes,
|
albumSortModes,
|
||||||
albumManualOrders,
|
albumManualOrders,
|
||||||
|
albumGrids,
|
||||||
|
albumCoverPositions,
|
||||||
} = deriveGallery(gallery);
|
} = deriveGallery(gallery);
|
||||||
|
|
||||||
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
||||||
@@ -412,6 +474,9 @@ export function getConfig(): AppConfig {
|
|||||||
albumHeroImages,
|
albumHeroImages,
|
||||||
albumSortModes,
|
albumSortModes,
|
||||||
albumManualOrders,
|
albumManualOrders,
|
||||||
|
albumGrids,
|
||||||
|
albumCoverPositions,
|
||||||
|
navLinks: sanitizeNavLinks(settings.navLinks),
|
||||||
cacheTtl: env.CACHE_TTL * 1000,
|
cacheTtl: env.CACHE_TTL * 1000,
|
||||||
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
||||||
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface SubpageConfig {
|
|||||||
essayFile?: string;
|
essayFile?: string;
|
||||||
essayText?: string;
|
essayText?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubpageObjectValue {
|
export interface SubpageObjectValue {
|
||||||
@@ -31,6 +33,8 @@ export interface SubpageObjectValue {
|
|||||||
essayFile?: string;
|
essayFile?: string;
|
||||||
essayText?: string;
|
essayText?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||||||
|
hidden?: boolean;
|
||||||
// Both album lists reuse AlbumEntryObject rather than inlining the shape:
|
// Both album lists reuse AlbumEntryObject rather than inlining the shape:
|
||||||
// the inline copies had already drifted (they never gained `heroImage`), and
|
// the inline copies had already drifted (they never gained `heroImage`), and
|
||||||
// this path is live for map-style subpages.
|
// this path is live for map-style subpages.
|
||||||
@@ -42,6 +46,12 @@ export interface SubpageObjectValue {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** EXPERIMENTAL: external link shown in the header navigation. */
|
||||||
|
export interface NavLinkConfig {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FooterConfig {
|
export interface FooterConfig {
|
||||||
name?: string;
|
name?: string;
|
||||||
instagram?: string;
|
instagram?: string;
|
||||||
@@ -70,14 +80,21 @@ export interface ThemeConfig {
|
|||||||
photoFrame: 'none' | 'passepartout' | 'shadow';
|
photoFrame: 'none' | 'passepartout' | 'shadow';
|
||||||
grain: boolean;
|
grain: boolean;
|
||||||
headerDot: boolean;
|
headerDot: boolean;
|
||||||
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic';
|
heroStyle: 'split' | 'fullbleed' | 'minimal' | 'stacked' | 'typographic' | 'mosaic' | 'cover';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridConfig {
|
export interface GridConfig {
|
||||||
columns: number;
|
columns: number;
|
||||||
gap: number;
|
gap: number;
|
||||||
aspectRatio: string;
|
aspectRatio: string;
|
||||||
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
|
layout:
|
||||||
|
| 'masonry'
|
||||||
|
| 'uniform'
|
||||||
|
| 'showcase'
|
||||||
|
| 'filmstrip'
|
||||||
|
| 'editorial-flow'
|
||||||
|
| 'essay'
|
||||||
|
| 'justified';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
@@ -119,6 +136,12 @@ export interface AppConfig {
|
|||||||
albumSortModes: Record<string, AlbumSortMode>;
|
albumSortModes: Record<string, AlbumSortMode>;
|
||||||
/** Pinned asset UUIDs per album for `sort: manual`, in display order. */
|
/** Pinned asset UUIDs per album for `sort: manual`, in display order. */
|
||||||
albumManualOrders: Record<string, string[]>;
|
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>;
|
||||||
|
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||||||
|
navLinks: NavLinkConfig[];
|
||||||
cacheTtl: number;
|
cacheTtl: number;
|
||||||
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
|
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
|
||||||
staleMaxAge: number;
|
staleMaxAge: number;
|
||||||
@@ -152,6 +175,10 @@ export interface AlbumEntryObject {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
/** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */
|
/** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */
|
||||||
assetOrder?: string[];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryYaml {
|
export interface GalleryYaml {
|
||||||
@@ -174,6 +201,7 @@ export interface GalleryYaml {
|
|||||||
essayFile?: string;
|
essayFile?: string;
|
||||||
essayText?: string;
|
essayText?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
grid?: {
|
grid?: {
|
||||||
columns?: number;
|
columns?: number;
|
||||||
gap?: number;
|
gap?: number;
|
||||||
@@ -223,6 +251,8 @@ export interface SettingsYaml {
|
|||||||
};
|
};
|
||||||
footer?: FooterConfig;
|
footer?: FooterConfig;
|
||||||
legal?: Partial<LegalConfig>;
|
legal?: Partial<LegalConfig>;
|
||||||
|
/** EXPERIMENTAL: external links appended to the header navigation. */
|
||||||
|
navLinks?: Array<{ label?: string; url?: string }>;
|
||||||
protection?: {
|
protection?: {
|
||||||
disableRightClick?: boolean;
|
disableRightClick?: boolean;
|
||||||
disableImageDrag?: boolean;
|
disableImageDrag?: boolean;
|
||||||
|
|||||||
@@ -81,8 +81,17 @@ export const VALID_HERO_STYLES = [
|
|||||||
'stacked',
|
'stacked',
|
||||||
'typographic',
|
'typographic',
|
||||||
'mosaic',
|
'mosaic',
|
||||||
|
'cover', // EXPERIMENTAL: fullscreen splash with a single "Enter" link
|
||||||
|
];
|
||||||
|
export const VALID_LAYOUTS = [
|
||||||
|
'masonry',
|
||||||
|
'uniform',
|
||||||
|
'showcase',
|
||||||
|
'filmstrip',
|
||||||
|
'editorial-flow',
|
||||||
|
'essay',
|
||||||
|
'justified', // EXPERIMENTAL: row-based layout with equal heights
|
||||||
];
|
];
|
||||||
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'essay'];
|
|
||||||
|
|
||||||
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||||
if (!raw) return { ...THEME_PRESETS.studio };
|
if (!raw) return { ...THEME_PRESETS.studio };
|
||||||
|
|||||||
@@ -461,7 +461,12 @@ class ImmichClient {
|
|||||||
* Get enriched subpage summaries for the homepage.
|
* Get enriched subpage summaries for the homepage.
|
||||||
*/
|
*/
|
||||||
async getSubpages(forceFresh = false): Promise<SubpageSummary[]> {
|
async getSubpages(forceFresh = false): Promise<SubpageSummary[]> {
|
||||||
const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false);
|
// hidden (EXPERIMENTAL) drops a subpage from every list this feeds (nav,
|
||||||
|
// homepage, hero) while getSubpageAlbums()/isValidSubpage() still resolve
|
||||||
|
// it — unlike enabled:false, which 404s the page entirely.
|
||||||
|
const activeSubpages = this.config.subpages.filter(
|
||||||
|
(sp) => sp.enabled !== false && sp.hidden !== true,
|
||||||
|
);
|
||||||
if (activeSubpages.length === 0) return [];
|
if (activeSubpages.length === 0) return [];
|
||||||
|
|
||||||
const albums = await this.getAlbums(forceFresh);
|
const albums = await this.getAlbums(forceFresh);
|
||||||
|
|||||||
Reference in New Issue
Block a user