release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

View File

@@ -6,6 +6,8 @@ import type { GridConfig } from '@/lib/config';
import type { LightboxWatermark } from '@/components/Lightbox';
import Image from 'next/image';
import React from 'react';
import { getServerDictionary } from '@/lib/i18n/server';
import { albumMetaDetail } from '@/lib/albumMeta';
interface AlbumDetailViewProps {
album: ImmichAlbum;
@@ -18,37 +20,16 @@ interface AlbumDetailViewProps {
heroImageUrl?: string;
heroBlurDataURL?: string;
watermark?: LightboxWatermark;
/** Whether the lightbox may offer its info panel at all. */
showExifPanel?: boolean;
/** Whether the header may name the camera and lens. Follows the `camera` group. */
showGear?: boolean;
/** Client proofing — favorite hearts, selection bar and send-off modal. */
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
}
/**
* Extra detail for the album header — shooting date and camera/lens, read off
* the first asset that carries EXIF. Both are rendered in their own spans and
* hidden by default; only presets that ask for them show them, so the header
* keeps reading "86 photos" everywhere else.
*/
function albumMetaDetail(album: ImmichAlbum): { date?: string; gear?: string } {
const first = album.assets.find((a) => a.exifInfo);
const exif = first?.exifInfo;
const taken = exif?.dateTimeOriginal || first?.fileCreatedAt;
const gear = [exif?.model, exif?.lensModel].filter(Boolean).join(' · ');
return {
...(taken ? { date: formatMonthYear(taken) } : {}),
...(gear ? { gear } : {}),
};
}
function formatMonthYear(iso: string): string | undefined {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
export function AlbumDetailView({
album,
images,
@@ -60,10 +41,13 @@ export function AlbumDetailView({
heroImageUrl,
heroBlurDataURL,
watermark,
showExifPanel = true,
showGear = true,
proofing,
allowMailto,
}: AlbumDetailViewProps) {
const metaDetail = albumMetaDetail(album);
const metaDetail = albumMetaDetail(album, showGear);
const t = getServerDictionary();
return (
<>
@@ -95,9 +79,7 @@ export function AlbumDetailView({
{album.description && <p className="album-header__description">{album.description}</p>}
</div>
<p className="album-header__meta">
<span className="album-header__meta-count">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
</span>
<span className="album-header__meta-count">{t.common.photos(images.length)}</span>
{metaDetail.date && <span className="album-header__meta-date"> · {metaDetail.date}</span>}
{metaDetail.gear && <span className="album-header__meta-gear">{metaDetail.gear}</span>}
</p>
@@ -107,6 +89,7 @@ export function AlbumDetailView({
layout={layout}
gridStyle={gridStyle}
watermark={watermark}
showExifPanel={showExifPanel}
proofing={proofing}
allowMailto={allowMailto}
/>

View File

@@ -9,6 +9,7 @@ import { FadeIn } from '@/components/FadeIn';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
interface EssayViewProps {
essay: ParsedEssay;
@@ -34,6 +35,7 @@ function EssayViewContent({
watermark,
proofing: proofingEnabled = false,
}: EssayViewProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -122,7 +124,7 @@ function EssayViewContent({
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
@@ -205,7 +207,7 @@ function EssayViewContent({
e.stopPropagation();
proofing.toggleFavorite(item.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '12px',
@@ -333,7 +335,9 @@ function EssayViewContent({
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
<button
type="button"
@@ -348,7 +352,7 @@ function EssayViewContent({
textDecoration: 'underline',
}}
>
Share & Export
{t.proofing.shareExport}
</button>
</div>
)}

View File

@@ -14,6 +14,7 @@ import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useDictionary } from '@/components/I18nProvider';
export interface PhotoItem {
id: string;
@@ -38,6 +39,11 @@ interface PhotoGridProps {
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
gridStyle?: React.CSSProperties;
watermark?: LightboxWatermark;
/**
* Offer the lightbox info panel. Off when every EXIF group is switched off,
* so the `i` key does not open an empty panel (#506).
*/
showExifPanel?: boolean;
/**
* Client proofing — favorite hearts, the selection bar and the send-off
* modal. Off unless the caller opts in, so a grid rendered outside the
@@ -61,7 +67,14 @@ function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
}
function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) {
function PhotoGridInner({
assets,
layout = 'masonry',
gridStyle,
watermark,
showExifPanel = true,
}: PhotoGridProps) {
const t = useDictionary();
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -190,8 +203,8 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
e.stopPropagation();
proofing.toggleFavorite(asset.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
title={isFav ? 'Remove favorite' : 'Add favorite'}
aria-label={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
title={isFav ? t.proofing.removeFavorite : t.proofing.addFavorite}
style={{
position: 'absolute',
top: '8px',
@@ -240,7 +253,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing]);
}, [displayedAssets, layout, openLightbox, proofing, t]);
return (
<>
@@ -284,7 +297,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
<button
type="button"
@@ -299,7 +314,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
textDecoration: 'underline',
}}
>
Share & Export
{t.proofing.shareExport}
</button>
</div>
)}
@@ -314,6 +329,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
onNext={goNext}
onPrev={goPrev}
watermark={watermark}
showExifToggle={showExifPanel}
/>
)}
</>

View File

@@ -2,6 +2,7 @@ import Link from 'next/link';
import Image from 'next/image';
import { imageUrl } from '@/lib/urls';
import { SubpageSectionConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
interface SubpageAlbum {
id: string;
@@ -27,22 +28,26 @@ interface SubpageGridViewProps {
sections?: SubpageSectionConfig[];
/** 1-based position among all subpages — feeds the "03 — Collection" kicker. */
index?: number;
/** `--subpage-columns` / `--subpage-gap` from the resolved grid config. */
gridStyle?: React.CSSProperties;
}
const pad2 = (n: number) => String(n).padStart(2, '0');
const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`;
const photoCount = (n: number) => getServerDictionary().common.photos(n);
function AlbumGrid({
albums,
placeholderMap,
slug,
gridStyle,
}: {
albums: SubpageAlbum[];
placeholderMap: Map<string, Placeholder | null>;
slug: string;
gridStyle?: React.CSSProperties;
}) {
return (
<div className="subpage-grid">
<div className="subpage-grid" style={gridStyle}>
{albums.map((album, i) => {
const ph = placeholderMap.get(album.id) ?? null;
return (
@@ -51,7 +56,10 @@ function AlbumGrid({
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
aria-label={`${album.albumName}, ${photoCount(album.assetCount)}`}
aria-label={getServerDictionary().subpage.coverAria(
album.albumName,
photoCount(album.assetCount),
)}
>
<span className="subpage-grid__item-media">
{album.albumThumbnailAssetId ? (
@@ -105,11 +113,13 @@ export function SubpageGridView({
coverPlaceholders,
sections,
index,
gridStyle,
}: SubpageGridViewProps) {
// Build lookup maps once
const albumMap = new Map(albums.map((a) => [a.id, a]));
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
const t = getServerDictionary();
const hasSections = sections && sections.length > 0;
const totalPhotos = albums.reduce((sum, a) => sum + a.assetCount, 0);
@@ -120,21 +130,21 @@ export function SubpageGridView({
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker" aria-hidden="true">
{pad2(index)} — Collection
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
</div>
<p className="subpage-header__meta" aria-hidden="true">
{albums.length} {albums.length === 1 ? 'album' : 'albums'} · {photoCount(totalPhotos)}
{t.common.albums(albums.length)} · {photoCount(totalPhotos)}
</p>
</header>
)}
{/* Typographic Table of Contents */}
{hasSections && (
<nav className="subpage-toc" aria-label="Sections">
<nav className="subpage-toc" aria-label={t.subpage.sectionsNav}>
{sections.map((sec, i) => (
<span key={sec.slug} className="subpage-toc__entry">
<a href={`#${sec.slug}`} className="subpage-toc__link">
@@ -160,13 +170,23 @@ export function SubpageGridView({
{sec.description && <p className="subpage-section__desc">{sec.description}</p>}
<div className="subpage-section__rule" aria-hidden="true" />
</header>
<AlbumGrid albums={sectionAlbums} placeholderMap={placeholderMap} slug={slug} />
<AlbumGrid
albums={sectionAlbums}
placeholderMap={placeholderMap}
slug={slug}
gridStyle={gridStyle}
/>
</section>
);
})
) : (
/* Flat layout (no sections) */
<AlbumGrid albums={albums} placeholderMap={placeholderMap} slug={slug} />
<AlbumGrid
albums={albums}
placeholderMap={placeholderMap}
slug={slug}
gridStyle={gridStyle}
/>
)}
</div>
);

View File

@@ -5,12 +5,14 @@
*/
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {
const t = getServerDictionary();
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label="Loading photos">
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}

View File

@@ -24,7 +24,13 @@ import {
assetAspectRatio,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import { getConfig, resolveProofing, type GridConfig } from '@/lib/config';
import {
buildCoverGridVars,
getConfig,
hasExifPanelContent,
resolveProofing,
type GridConfig,
} from '@/lib/config';
import { isProtected, isAuthenticated } from '@/lib/auth';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import PasswordGate from '@/components/PasswordGate';
@@ -34,6 +40,7 @@ import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
import { loadEssayFromFile } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -104,6 +111,10 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
}
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*/
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
@@ -173,15 +184,28 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// Build grid CSS custom properties, optionally merging subpage overrides
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
const g = { ...config.grid, ...overrides };
// `gap` is only emitted when someone chose one. Each preset declares its own
// photo spacing as part of its look (2px minimal, 40px monograph), and
// emitting the resolved fallback unconditionally overwrote that — the three
// presets that fought back with a hardcoded `column-gap` then ignored the
// setting entirely, which is #513. Inline beats the preset's declaration, so
// an explicit value still wins everywhere.
return {
'--grid-columns': g.columns,
'--grid-gap': `${g.gap}px`,
...(overrides?.gap != null || config.gridGapExplicit ? { '--grid-gap': `${g.gap}px` } : {}),
'--grid-aspect-ratio': g.aspectRatio,
} as React.CSSProperties;
};
const resolveLayout = (overrides?: Partial<GridConfig>) =>
overrides?.layout ?? config.grid.layout;
// The album-cover grid on a subpage takes its column count from the same
// config as the photo grids, so "3 columns" in the admin means three columns
// everywhere. `gap` deliberately does not follow the global setting — see
// buildCoverGridVars() for why.
const buildCoverGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties =>
buildCoverGridVars(overrides, config.grid.columns) as React.CSSProperties;
// Client proofing — `proofing.enabled` in settings.yaml is the default and a
// subpage's own `proofing:` flag overrides it in either direction. Albums
// reached without a subpage follow the global setting.
@@ -230,7 +254,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -247,6 +271,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
{...heroData}
@@ -298,7 +324,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(allAssets, config.exifOnHover);
const images = toPhotoItems(allAssets, config.exif.onHover && config.exif.camera);
// Fallback structured essay if layout: 'essay' is set without custom markdown file
if (!essayParsed) {
@@ -355,7 +381,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound();
}
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -371,8 +397,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, result.subpage.grid))}
subtitle={result.subpage.subtitle}
backLinkHref="/"
backLinkLabel="Back to Gallery"
backLinkLabel={getServerDictionary().common.backToGallery}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
{...heroData}
@@ -416,6 +444,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums={albumsWithHero}
coverPlaceholders={coverPlaceholders}
sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.grid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
/>
);
@@ -435,7 +464,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound();
}
const images = toPhotoItems(album.assets, config.exifOnHover);
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
// Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -450,8 +479,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
layout={resolveLayout(mergeAlbumGrid(album.id))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
backLinkHref="/"
backLinkLabel="Back to Gallery"
backLinkLabel={getServerDictionary().common.backToGallery}
watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
{...heroData}

View File

@@ -11,6 +11,7 @@ import type { Metadata } from 'next';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { notFound } from 'next/navigation';
import './about.css';
@@ -51,7 +52,8 @@ async function getAboutContent() {
export async function generateMetadata(): Promise<Metadata> {
const { meta, body } = await getAboutContent();
const title = meta.name ? `About — ${meta.name}` : 'About';
const t = getServerDictionary();
const title = meta.name ? t.about.metaTitle(meta.name) : t.about.title;
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
return {
@@ -72,6 +74,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AboutPage() {
const config = getConfig();
if (!config.aboutEnabled) notFound();
const t = getServerDictionary();
const { meta, body } = await getAboutContent();
@@ -86,7 +89,7 @@ export default async function AboutPage() {
{meta.portrait ? (
<Image
src={imageUrl(meta.portrait, 'preview')}
alt={meta.name || 'Portrait'}
alt={meta.name || t.about.portraitAlt}
className="about__portrait"
fill
sizes="(max-width: 768px) 100vw, 40vw"
@@ -102,7 +105,7 @@ export default async function AboutPage() {
{/* ── Text ─────────────────────────────────────── */}
<div className="about__text-col">
<p className="about__kicker" aria-hidden="true">
About
{t.about.kicker}
</p>
{meta.name && <h1 className="about__name">{meta.name}</h1>}
{meta.location && <p className="about__location">{meta.location}</p>}
@@ -123,7 +126,7 @@ export default async function AboutPage() {
{meta.gear && meta.gear.length > 0 && (
<>
<hr className="about__rule" />
<h2 className="about__section-label">Gear</h2>
<h2 className="about__section-label">{t.about.gear}</h2>
<ul className="about__gear">
{meta.gear.map((item) => (
<li key={item}>{item}</li>

View File

@@ -1581,6 +1581,323 @@
flex-direction: column;
}
/* ── Grouped feature settings (#510) ──────────────────────────
The features panel used to be one flat grid of identical cards. These give it
the structure the config already had: labelled groups, and sub-settings that
look like sub-settings instead of peers. */
.settings-group {
margin-bottom: 1.75rem;
}
.settings-group-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.65rem;
}
.settings-group-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--admin-text-secondary);
}
/* Marks a group whose switches publish something, so it does not read like the
one next to it that controls a fade. */
.settings-group-chip {
margin-left: 4px;
padding: 2px 7px;
border: 1px solid var(--admin-border);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--admin-text-secondary);
}
.settings-group-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
line-height: 1.35;
}
.settings-subpanel {
padding: 1rem 1.25rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
}
.settings-subpanel-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-bottom: 0.75rem;
}
.settings-subpanel-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.88rem;
font-weight: 600;
color: var(--admin-text);
}
.settings-subpanel-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
}
/* Quiet marker on an album Immich still considers private (#515). Deliberately
not styled as a warning: publishing it is allowed, this only says what it is. */
.picker-item-note {
display: inline-flex;
align-items: center;
margin-top: 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.02em;
color: var(--admin-text-muted);
}
/* ── Config doctor report (#491) ──────────────────────────────
Each finding is one line with a severity dot: the point is scanning a list
for the ones that are not green, not reading prose. */
.doctor-list {
margin: 0;
padding: 0;
list-style: none;
}
.doctor-item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--admin-border);
}
.doctor-item:last-child {
border-bottom: none;
}
.doctor-dot {
width: 8px;
height: 8px;
margin-top: 0.42rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--admin-success);
}
.doctor-item--warn .doctor-dot {
background: var(--admin-accent);
}
.doctor-item--error .doctor-dot {
background: var(--admin-error);
}
.doctor-item-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.doctor-item-title {
font-size: 0.84rem;
font-weight: 600;
color: var(--admin-text);
}
/* A finding that needs nothing recedes; the eye should land on the others. */
.doctor-item--ok .doctor-item-title {
font-weight: 500;
color: var(--admin-text-secondary);
}
.doctor-item-detail {
font-size: 0.75rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
/* The metadata group as one card with an expander: four permanently visible
sub-switches read as four decisions, when they are details of one. */
.metadata-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
transition: border-color 0.15s ease;
}
.metadata-card.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.metadata-card-main {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: none;
border: none;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.metadata-details-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 1.25rem 0.9rem;
padding: 0;
background: none;
border: none;
color: var(--admin-text-secondary);
font: inherit;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.metadata-details-btn:hover {
color: var(--admin-text);
}
.metadata-details-chevron {
transition: transform 0.15s ease;
}
.metadata-details-chevron.open {
transform: rotate(180deg);
}
.metadata-details {
padding: 0 1.25rem 0.6rem;
border-top: 1px solid var(--admin-border);
}
/* The first row sits directly under that border and does not need its own. */
.metadata-details .setting-row:first-child {
border-top: none;
}
.setting-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0;
background: none;
border: none;
border-top: 1px solid var(--admin-border);
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
/* A sub-setting of the rows above it — indented so the dependency is visible
rather than only true. */
.setting-row--indented {
padding-left: 0.9rem;
margin-top: 0.35rem;
}
.setting-row--disabled {
cursor: default;
}
.setting-row--disabled .setting-row-title,
.setting-row--disabled .setting-row-desc {
color: var(--admin-text-muted);
}
.setting-row-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.setting-row-title {
font-size: 0.81rem;
font-weight: 500;
color: var(--admin-text);
}
.setting-row-desc {
font-size: 0.72rem;
color: var(--admin-text-muted);
line-height: 1.3;
}
/* Says why an inert row is inert, instead of leaving it looking broken. */
.setting-row-hint {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 0.2rem;
font-size: 0.69rem;
color: var(--admin-text-secondary);
}
.switch-toggle--sm {
width: 34px;
height: 20px;
border-radius: 10px;
}
.switch-toggle--sm .switch-slider {
width: 14px;
height: 14px;
}
.switch-toggle--sm.on .switch-slider {
transform: translateX(14px);
}
/* An inert switch must not look like an off switch someone can turn on. */
.setting-row--disabled .switch-toggle {
background: var(--admin-bg-elevated);
}
.setting-row--disabled .switch-slider {
background: var(--admin-text-muted);
}
/* Explains an unfinished installation before the indicators below it, which
would otherwise read as a fault rather than a pending step. */
.status-setup-note {
margin: 0 0 0.35rem;
padding: 0.5rem 0.6rem;
border-radius: 6px;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
font-size: 0.75rem;
line-height: 1.45;
}
.status-setup-note a {
color: var(--admin-text);
text-decoration: underline;
}
.status-item {
display: flex;
justify-content: space-between;

View File

@@ -1,9 +1,11 @@
'use client';
import { useState, useEffect, type ReactNode } from 'react';
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal';
import DoctorModal from './DoctorModal';
import type { DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
interface Props {
@@ -26,6 +28,10 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// Diagnostics & Backup state
const [showStatus, setShowStatus] = useState(false);
const [showBackupModal, setShowBackupModal] = useState(false);
const [showDoctor, setShowDoctor] = useState(false);
// The badge's job is "does anything need me?", and the doctor is the only
// check that can answer that beyond "is Immich up" (#491).
const [doctorLevel, setDoctorLevel] = useState<DoctorLevel | null>(null);
const [status, setStatus] = useState<any>(null);
const [statusLoading, setStatusLoading] = useState(false);
// Distinct from `status === null`: the check itself did not run. Without this
@@ -80,16 +86,37 @@ export default function AdminDashboard({ onLogout, children }: Props) {
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
};
const fetchDoctor = useCallback(async () => {
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) return;
const data = await res.json();
setDoctorLevel(data.level ?? null);
} catch {
// A failed check must not colour the badge — "unknown" is not "broken".
}
}, []);
useEffect(() => {
fetchStatus();
}, []);
fetchDoctor();
}, [fetchDoctor]);
const immichIndicator = indicator(
status?.immich?.status === 'connected',
'Connected',
'Disconnected',
);
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
const configIndicator =
!statusLoading && !statusError && status?.config?.status === 'setup'
? { className: 'unknown', label: 'Not set up' }
: indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
// An installation that was never finished is not a fault. It used to render
// as "System Degraded" with no further hint, which sent operators looking for
// a network problem that did not exist (#507).
const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false;
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className="admin-dashboard">
@@ -112,7 +139,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
className={`status-badge-btn ${
setupIncomplete || doctorLevel === 'warn'
? 'unknown'
: immichIndicator.className === 'error' || doctorLevel === 'error'
? 'disconnected'
: immichIndicator.className === 'ok'
? 'connected'
: 'unknown'
}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
@@ -123,11 +158,17 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<span className="status-text">
{statusLoading
? 'Checking...'
: immichIndicator.className === 'ok'
? 'System OK'
: setupIncomplete
? 'Setup Incomplete'
: immichIndicator.className === 'error'
? 'System Degraded'
: 'Status Unknown'}
: doctorLevel === 'error'
? 'Needs Attention'
: doctorLevel === 'warn'
? 'Check Diagnostics'
: immichIndicator.className === 'ok'
? 'System OK'
: 'Status Unknown'}
</span>
</button>
@@ -147,6 +188,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</button>
</div>
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
@@ -177,6 +231,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</div>
</div>
<div className="status-dropdown-footer">
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowDoctor(true);
}}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</button>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
@@ -226,6 +289,14 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<main className="admin-main">{children}</main>
<DoctorModal
isOpen={showDoctor}
onClose={() => {
setShowDoctor(false);
fetchDoctor();
}}
/>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}

View File

@@ -11,6 +11,8 @@ interface ImmichAlbumInfo {
thumbnailAssetId: string | null;
assetCount: number;
isConfigured: boolean;
/** Immich's own flag. Undefined on older Immich — silence beats a false alarm. */
shared?: boolean;
}
interface Props {
@@ -89,6 +91,13 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{album.assetCount} photos
{album.description && ` · ${album.description.slice(0, 40)}`}
</span>
{/* Not a warning and not a barrier: publishing an unshared
album has always worked, because Immich ignores the
?shared=true filter this list is fetched with (#515). It
is here so nobody publishes a private album unaware. */}
{album.shared === false && (
<span className="picker-item-note">Not shared in Immich</span>
)}
</div>
<div className="picker-item-action">
{isUsed ? (

View File

@@ -0,0 +1,131 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const LEVEL_LABEL: Record<DoctorLevel, string> = {
ok: 'OK',
warn: 'Check',
error: 'Problem',
};
/**
* The config doctor's report (#491).
*
* Most "it does not work for me" reports are one of a handful of
* misconfigurations that fail silently — a wrong proxy hop count, a deleted
* album ID, a plaintext password. Each of those already logs a warning to the
* server log, where nobody looks. This puts them where the operator is.
*/
export default function DoctorModal({ isOpen, onClose }: Props) {
const [findings, setFindings] = useState<DoctorFinding[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const run = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) throw new Error(`Diagnostics failed (${res.status})`);
const data = await res.json();
setFindings(data.findings || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
setCopied(false);
run();
}
}, [isOpen, run]);
useScrollLock(isOpen);
if (!isOpen) return null;
/** Markdown, so it can be pasted straight into a GitHub issue. */
async function copyReport() {
const body = findings
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
.join('\n');
try {
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n\n${body}\n`);
setCopied(true);
} catch {
setError('Could not copy — your browser refused clipboard access.');
}
}
const problems = findings.filter((f) => f.level !== 'ok').length;
return (
<div className="backup-modal-backdrop" onClick={onClose}>
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="backup-modal-header">
<div>
<h2>Diagnostics</h2>
<p className="backup-modal-subtitle">
{loading
? 'Checking…'
: problems === 0
? 'Nothing needs your attention.'
: `${problems} of ${findings.length} checks want a look.`}
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
<Icons.IconX size={18} />
</button>
</div>
<div className="backup-modal-body">
{error && <div className="admin-error">{error}</div>}
{loading && !findings.length ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<ul className="doctor-list">
{findings.map((f) => (
<li key={f.id} className={`doctor-item doctor-item--${f.level}`}>
<span className="doctor-dot" aria-hidden="true" />
<span className="doctor-item-body">
<span className="doctor-item-title">{f.title}</span>
<span className="doctor-item-detail">{f.detail}</span>
</span>
</li>
))}
</ul>
)}
</div>
<div className="backup-modal-footer">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !findings.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,12 @@ import {
isAlbumSortMode,
type AlbumSortMode,
} from '@/lib/albumSort';
import type { AlbumEntryObject } from '@/lib/config/schema';
import {
COVER_GRID_COLUMNS_MAX,
COVER_GRID_COLUMNS_MIN,
COVER_GRID_GAP_MAX,
type AlbumEntryObject,
} from '@/lib/config/schema';
import {
IconArrowDown,
IconArrowUp,
@@ -122,6 +127,21 @@ interface Subpage {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
}
/**
* Drops keys the editor cleared so an emptied field never persists as `null`
* in gallery.yaml, and returns undefined once nothing is left — `handleSave`
* only writes `grid` when it is truthy.
*/
function normalizeSubpageGrid(grid: Subpage['grid']): Subpage['grid'] {
if (!grid) return undefined;
const next = { ...grid };
for (const key of Object.keys(next) as Array<keyof typeof next>) {
const value = next[key];
if (value === undefined || value === '') delete next[key];
}
return Object.keys(next).length > 0 ? next : undefined;
}
interface GalleryState {
hero: string[];
albums: AlbumEntry[];
@@ -1452,6 +1472,80 @@ export default function PageBuilder() {
</option>
</select>
</div>
{/* Album cover grid — hidden in essay mode, which
renders a story instead of a cover grid. The
album count is deliberately not checked: it
changes in this very drawer, and a field that
vanishes while you edit is worse than one that
has no effect yet. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Album Cover Grid</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={COVER_GRID_COLUMNS_MIN}
max={COVER_GRID_COLUMNS_MAX}
value={sp.grid?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => {
const raw = e.target.value;
const columns = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
columns:
columns != null &&
columns >= COVER_GRID_COLUMNS_MIN &&
columns <= COVER_GRID_COLUMNS_MAX
? columns
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
<input
type="number"
min={0}
max={COVER_GRID_GAP_MAX}
value={sp.grid?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => {
const raw = e.target.value;
const gap = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
gap:
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
? gap
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
</div>
<p className="admin-field-hint">
Overrides how the album covers are tiled on this page. Leave the
column count empty to follow the site-wide grid setting, and the
gap empty to keep the theme&apos;s own spacing. Tablet widths
show at most two covers per row.
</p>
</div>
)}
</div>
{/* Visual Photo Essay Builder (if layout === 'essay') */}

View File

@@ -8,12 +8,22 @@ import SaveBar from './SaveBar';
// Direct import from the theme module, not from '@/lib/config': the config
// index pulls in `fs` and cannot be bundled into a client component.
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
interface Settings {
title?: string;
subtitle?: string;
lang?: string;
sitePassword?: string;
mode?: 'light' | 'dark' | 'auto';
exifOnHover?: boolean;
exif?: {
camera?: boolean;
settings?: boolean;
location?: boolean;
caption?: boolean;
};
map?: boolean;
transitions?: boolean;
scrollToTop?: boolean;
@@ -204,6 +214,91 @@ interface SettingsEditorProps {
section?: string;
}
/**
* One labelled block of related switches.
*
* The features panel was a single flat grid of identical cards, so nothing said
* which switches belonged together — and a switch that publishes private notes
* looked exactly like one that controls a fade (#510).
*/
function FeatureGroup({
icon,
title,
description,
chip,
children,
}: {
icon: React.ReactNode;
title: string;
description: string;
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
chip?: string;
children: React.ReactNode;
}) {
return (
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
{icon}
{title}
{chip && <span className="settings-group-chip">{chip}</span>}
</span>
<span className="settings-group-desc">{description}</span>
</div>
{children}
</div>
);
}
/**
* A switch inside a group panel. Deliberately lighter than a toggle card: these
* are settings *of* the panel they sit in, and rendering them as peers of the
* top-level cards is what hid the structure in the first place.
*/
function SettingRow({
title,
description,
checked,
onToggle,
disabled,
hint,
indented,
}: {
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** A row whose parent switch is off — shown, but inert and explained. */
disabled?: boolean;
hint?: string;
indented?: boolean;
}) {
return (
<button
type="button"
className={`setting-row${indented ? ' setting-row--indented' : ''}${
disabled ? ' setting-row--disabled' : ''
}`}
onClick={onToggle}
disabled={disabled}
aria-pressed={checked}
>
<span className="setting-row-info">
<span className="setting-row-title">{title}</span>
<span className="setting-row-desc">{description}</span>
{hint && (
<span className="setting-row-hint">
<Icons.IconBan size={11} /> {hint}
</span>
)}
</span>
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
);
}
export default function SettingsEditor({ section }: SettingsEditorProps) {
const router = useRouter();
// An unknown section in the URL falls back to General instead of an empty panel.
@@ -212,6 +307,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
: 'general';
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -330,6 +428,25 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}
/** Write several paths in one state update — used by the metadata master switch. */
function updateMany(entries: Record<string, unknown>) {
setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s));
for (const [path, value] of Object.entries(entries)) {
const parts = path.split('.');
let obj = copy;
for (let i = 0; i < parts.length - 1; i++) {
if (!obj[parts[i]]) obj[parts[i]] = {};
obj = obj[parts[i]];
}
obj[parts[parts.length - 1]] = value;
}
return copy;
});
setDirty(true);
setSaveMessage('');
}
function update(path: string, value: unknown) {
setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s));
@@ -457,6 +574,34 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
);
}
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
// The master switch has no key of its own and needs none: "off" is all four
// groups off, a state the site already acts on — with nothing left to show,
// the lightbox withdraws its info button and the `i` key entirely (#506).
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
const metadataSummary =
[
exif.camera && 'Camera & lens',
exif.settings && 'Exposure',
exif.location && 'Location',
exif.caption && 'Description',
]
.filter(Boolean)
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
/** Turning it back on selects everything; the details below narrow it again. */
const toggleMetadata = () =>
updateMany({
'exif.camera': !anyMetadata,
'exif.settings': !anyMetadata,
'exif.location': !anyMetadata,
'exif.caption': !anyMetadata,
});
return (
<div className="settings-editor">
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
@@ -537,6 +682,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<option value="es">Español (ES)</option>
<option value="ja">日本語 (JA)</option>
</select>
<p className="admin-field-hint">
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}.
Other languages still set <code>&lt;html lang&gt;</code> and date formatting, but
show the English interface. The admin panel is always English.
</p>
</div>
<div className="settings-section-divider" />
@@ -550,136 +700,224 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</p>
</div>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.exifOnHover !== false ? 'active' : ''}`}
onClick={() => update('exifOnHover', settings.exifOnHover === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> EXIF Data on Hover
</span>
<span className="toggle-card-desc">
Display camera gear, lens, aperture &amp; shutter speed on hover
</span>
</div>
<div className={`switch-toggle ${settings.exifOnHover !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
onClick={() => update('map', !settings.map)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconMap size={16} /> Interactive GPS Map
</span>
<span className="toggle-card-desc">
Enable /map view showing photo locations on a world map
</span>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
onClick={() => update('transitions', settings.transitions === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconSparkles size={16} /> Smooth Page Transitions
</span>
<span className="toggle-card-desc">
Enable subtle fade-in animations between page navigation
</span>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
</span>
<span className="toggle-card-desc">
Show a floating arrow that returns visitors to the top of long pages
</span>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
onClick={() => update('analytics', settings.analytics === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconBarChart size={16} /> Analytics Tracking
</span>
<span className="toggle-card-desc">
Collect anonymous privacy-friendly visit statistics
</span>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconHeart size={16} /> Client Proofing &amp; Favorites
</span>
<span className="toggle-card-desc">
Allow visitors &amp; clients to heart, filter, and export favorite photo
selections
</span>
</div>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
<FeatureGroup
icon={<Icons.IconFrame size={13} />}
title="Pages &amp; motion"
description="Chrome the visitor sees on every page."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<span className="switch-slider" />
</div>
</button>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
</span>
<span className="toggle-card-desc">
Show a portrait, bio, and gear section on your portfolio
</span>
</div>
<div
className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
<button
type="button"
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
onClick={() => update('transitions', settings.transitions === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconSparkles size={16} /> Smooth Page Transitions
</span>
<span className="toggle-card-desc">
Enable subtle fade-in animations between page navigation
</span>
</div>
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
</span>
<span className="toggle-card-desc">
Show a floating arrow that returns visitors to the top of long pages
</span>
</div>
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconShieldCheck size={13} />}
title="What each photo reveals"
description="Published to anyone who opens a photo."
chip="visible to visitors"
>
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
<button
type="button"
className="metadata-card-main"
onClick={toggleMetadata}
aria-pressed={anyMetadata}
>
<span className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> Photo metadata
</span>
<span className="toggle-card-desc">{metadataSummary}</span>
</span>
<span className="toggle-card-desc">
Show a portrait, bio, and gear section on your portfolio
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</div>
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</button>
<button
type="button"
className="metadata-details-btn"
onClick={() => setMetadataOpen((open) => !open)}
aria-expanded={metadataOpen}
>
Details
<Icons.IconChevronDown
size={13}
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
/>
</button>
{metadataOpen && (
<div className="metadata-details">
<SettingRow
title="Camera &amp; Lens"
description="Body, lens and focal length"
checked={exif.camera}
onToggle={() => update('exif.camera', !exif.camera)}
/>
<SettingRow
title="Exposure Settings"
description="Aperture, shutter speed and ISO"
checked={exif.settings}
onToggle={() => update('exif.settings', !exif.settings)}
/>
<SettingRow
title="Location"
description="City and country from the photo's GPS data"
checked={exif.location}
onToggle={() => update('exif.location', !exif.location)}
/>
<SettingRow
title="Photo Description"
description="The description written in Immich"
checked={exif.caption}
onToggle={() => update('exif.caption', !exif.caption)}
/>
{/* Dependent, not a peer: this only controls the grid overlay, and
the overlay carries camera and lens. With those off it has
nothing to show, which used to leave it switched on and
silently doing nothing (#510). */}
<SettingRow
indented
title="Summary on grid hover"
description="Camera and lens over the photo, not only in the lightbox"
checked={settings.exifOnHover !== false}
disabled={!exif.camera}
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
/>
</div>
)}
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconHeart size={13} />}
title="What visitors can do"
description="Optional pages and interactions."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
onClick={() => update('map', !settings.map)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconMap size={16} /> Interactive GPS Map
</span>
<span className="toggle-card-desc">
Enable /map view showing photo locations on a world map
</span>
</div>
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconHeart size={16} /> Client Proofing &amp; Favorites
</span>
<span className="toggle-card-desc">
Allow visitors &amp; clients to heart, filter, and export favorite photo
selections
</span>
</div>
<div
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconBarChart size={13} />}
title="Measurement"
description="Cookieless and self-hosted — nothing leaves your server."
>
<div className="admin-toggle-cards-grid">
<button
type="button"
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
onClick={() => update('analytics', settings.analytics === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconBarChart size={16} /> Analytics Tracking
</span>
<span className="toggle-card-desc">
Collect anonymous privacy-friendly visit statistics
</span>
</div>
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</FeatureGroup>
<div className="admin-field favicon-field">
<label>Favicon</label>
@@ -758,6 +996,23 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</p>
</div>
<div className="admin-field">
<label>Visitor Default Mode</label>
<select
value={settings.mode || 'dark'}
onChange={(e) => update('mode', e.target.value)}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Follow the visitor's system</option>
</select>
<p className="admin-field-hint">
What a first-time visitor sees. The light/dark toggle in the site header still
lets them choose, and their choice is remembered on their device. The preview
below is your own, and does not change this.
</p>
</div>
<div className="admin-field">
<label>Preset</label>
<div className="preset-card-grid">
@@ -1256,7 +1511,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<h3>
<Icons.IconColumns size={18} /> Spacing &amp; Columns
</h3>
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
<p className="settings-section-sub">
Adjust column counts and grid gap spacing. The column count also tiles the album
covers on a subpage; their spacing stays with the theme unless a page overrides
it.
</p>
</div>
<div className="admin-field-row">
@@ -1618,6 +1877,35 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
{activeSection === 'security' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLock size={18} /> Site Password
</h3>
<p className="settings-section-sub">
Puts the whole public site behind one password — useful while a portfolio is still
being built, or for a folio only ever shown to clients.
</p>
</div>
<div className="admin-field">
<label>Site Password</label>
<input
type="text"
value={settings.sitePassword || ''}
onChange={(e) => update('sitePassword', e.target.value)}
placeholder="Leave empty for a public site"
autoComplete="off"
/>
<p className="admin-field-hint">
Stored in <code>settings.yaml</code>. Log in once and the server log prints a{' '}
<code>scrypt:…</code> hash to paste back here instead of the plaintext. The{' '}
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin
panel keeps its own password and is never behind this gate.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark
@@ -1730,15 +2018,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</select>
</div>
<div className="admin-field">
{/* Read through the same normaliser the lightbox uses, so a
config written as a percentage shows as "90%" here rather
than "9000%" — and is written back as a fraction on save. */}
<label>
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
Opacity (
{Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
%)
</label>
<input
type="range"
min="0.1"
max="0.8"
max="1"
step="0.05"
value={settings.watermark?.opacity ?? 0.3}
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
/>
</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The Dockerfile's HEALTHCHECK probes this route. A config that cannot be
* parsed therefore used to restart the container in a loop — at exactly the
* moment the app was still repairable through /admin, and when restarting
* cannot fix a YAML typo anyway (#519).
*/
const configOrNull = vi.fn<() => object | null>(() => ({}));
vi.mock('@/lib/config', () => ({
getConfigOrNull: () => configOrNull(),
}));
vi.mock('@/lib/env', () => ({ env: { RATE_LIMIT_RPM: 1500, TRUSTED_PROXY_HOPS: 0 } }));
vi.mock('@/lib/immich', () => ({
immich: { ping: vi.fn(async () => true) },
}));
import { NextRequest } from 'next/server';
const req = () => new NextRequest('http://localhost/api/health');
/**
* The route caches Immich's answer for ten seconds in a module variable, so
* each case needs its own module instance — otherwise the second test reads the
* first one's verdict.
*/
async function route() {
vi.resetModules();
return (await import('../health/route')).GET;
}
beforeEach(() => {
vi.clearAllMocks();
configOrNull.mockReturnValue({});
});
describe('GET /api/health', () => {
it('is healthy when the config parses and Immich answers', async () => {
const res = await (await route())(req());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.status).toBe('ok');
expect(body.config).toBe('ok');
});
it('stays healthy when the config cannot be parsed', async () => {
configOrNull.mockReturnValue(null);
const res = await (await route())(req());
const body = await res.json();
expect(res.status).toBe(200);
expect(body.status).toBe('setup');
expect(body.config).toBe('invalid');
});
/** Immich being down is a real degradation and keeps its 503. */
it('still reports 503 when Immich is unreachable', async () => {
const GET = await route();
const { immich } = await import('@/lib/immich');
(immich.ping as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(false);
const res = await GET(req());
expect(res.status).toBe(503);
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
/**
* Route handlers do not render through the root layout, so the page-level site
* gate does not cover them. Without a check of their own, a locked site would
* still stream every photo to anyone holding an asset URL.
*
* Adding a public content route means adding a row to GATED — a route that
* forgets its guard then fails this suite instead of shipping. The OPEN list is
* the deliberate counterpart: /api/health must stay reachable (a locked site
* still has to answer a container health probe), and the login endpoint cannot
* sit behind the lock it exists to open.
*/
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283/api',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
STALE_MAX_AGE: 600,
IMAGE_CACHE_VERSION: '1',
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 1500,
TRUSTED_PROXY_HOPS: 0,
},
}));
const config = {
authSecret: 'test-secret-that-is-at-least-32-chars-long',
sitePassword: 'letmein',
rateLimitRpm: 1500,
exifOnHover: true,
map: true,
subpages: [],
albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
}));
/** Routes read `nextUrl` and the client IP, so a bare Request is not enough. */
const request = (path: string) => new NextRequest(`http://localhost${path}`);
const params = (id: string) => ({ params: Promise.resolve({ id }) });
const GATED: { name: string; call: () => Promise<Response> }[] = [
{
name: 'GET /api/image/[id]',
call: async () =>
(await import('../image/[id]/route')).GET(
request('/api/image/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/video/[id]',
call: async () =>
(await import('../video/[id]/route')).GET(
request('/api/video/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/exif/[id]',
call: async () =>
(await import('../exif/[id]/route')).GET(
request('/api/exif/tok') as never,
params('tok') as never,
),
},
{
name: 'GET /api/map',
call: async () => (await import('../map/route')).GET(request('/api/map') as never),
},
{
name: 'GET /api/og',
call: async () => (await import('../og/route')).GET(request('/api/og?title=x') as never),
},
];
describe('public content routes behind a locked site', () => {
beforeEach(() => {
config.sitePassword = 'letmein';
});
it.each(GATED)('$name answers 401 without a session', async ({ call }) => {
const res = await call();
expect(res.status).toBe(401);
});
it.each(GATED)('$name does not cache the refusal', async ({ call }) => {
const res = await call();
expect(res.headers.get('Cache-Control')).toBe('no-store');
});
it.each(GATED)('$name stops gating once no password is set', async ({ name, call }) => {
config.sitePassword = '';
/*
* Everything past the gate is unmocked, so a route may answer with its own
* error or throw outright (Immich is absent, /api/map wants Next's request
* store). Either is fine — the only claim here is that an open site does
* not answer 401, i.e. the guard is genuinely opt-in.
*/
const status = await call().then(
(res) => res.status,
() => 'threw past the gate',
);
expect(status, name).not.toBe(401);
});
});
describe('routes that must stay open', () => {
it('GET /api/health answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (await import('../health/route')).GET(request('/api/health') as never);
// A health probe runs without cookies; a 401 here takes the container down.
expect(res.status).not.toBe(401);
});
it('POST /api/auth is not behind the lock it opens', async () => {
config.sitePassword = 'letmein';
const res = await (
await import('../auth/route')
).POST(
new Request('http://localhost/api/auth', {
method: 'POST',
body: JSON.stringify({ slug: 'site', password: 'letmein', type: 'site' }),
}) as never,
);
expect(res.status).toBe(200);
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: vi.fn(),
@@ -73,6 +74,14 @@ const ROUTES: {
method: 'GET',
args: () => [],
},
{
// Reports album titles, where passwords live and the proxy topology.
name: 'GET /api/admin/doctor',
path: 'doctor',
load: () => import('../doctor/route'),
method: 'GET',
args: () => [new NextRequest('http://localhost/api/admin/doctor')],
},
{
name: 'GET /api/admin/assets',
path: 'assets',

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* The report names album titles, password locations and proxy topology. Like
* every other /api/admin route it has to check both guards itself — there is no
* shared middleware (see admin-guards.test.ts).
*/
const enabled = vi.fn(() => true);
const authed = vi.fn(async () => true);
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => enabled(),
isAdminAuthenticated: async () => authed(),
COOKIE_NAME: 'folio_admin_session',
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({
needsCredentials: true,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [],
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000,
}),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
import { GET } from '../doctor/route';
import { NextRequest } from 'next/server';
function req(headers: Record<string, string> = {}) {
return new NextRequest('http://localhost/api/admin/doctor', { headers });
}
beforeEach(() => {
vi.clearAllMocks();
enabled.mockReturnValue(true);
authed.mockResolvedValue(true);
});
describe('GET /api/admin/doctor', () => {
it('refuses when the admin panel is disabled', async () => {
enabled.mockReturnValue(false);
expect((await GET(req())).status).toBe(403);
});
it('refuses an unauthenticated caller', async () => {
authed.mockResolvedValue(false);
expect((await GET(req())).status).toBe(401);
});
it('reports findings and the worst level', async () => {
const body = await (await GET(req())).json();
expect(Array.isArray(body.findings)).toBe(true);
expect(['ok', 'warn', 'error']).toContain(body.level);
// Missing credentials must surface as an error, not a silent pass.
expect(body.level).toBe('error');
});
/** The proxy check reads the chain off the request being served. */
it('measures the forwarded chain of the incoming request', async () => {
const body = await (await GET(req({ 'x-forwarded-for': '203.0.113.9, 10.0.0.2' }))).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('warn');
expect(proxy.title).toContain('2');
});
/**
* Next writes X-Forwarded-For itself for a direct request, so this must stay
* quiet — otherwise the warning fires on every proxy-less deployment.
*/
it('stays quiet on the single entry Next synthesises', async () => {
const body = await (await GET(req({ 'x-forwarded-for': '127.0.0.1' }))).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('ok');
});
it('trusts a lone entry that a proxy header corroborates', async () => {
const body = await (
await GET(req({ 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '203.0.113.9' }))
).json();
const proxy = body.findings.find((f: { id: string }) => f.id === 'proxy-hops');
expect(proxy.level).toBe('warn');
});
});

View File

@@ -20,11 +20,21 @@ vi.mock('@/lib/admin/yaml-service', () => ({
listBackups: vi.fn(async () => []),
}));
vi.mock('@/lib/config', () => ({
getConfig: vi.fn(() => ({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
})),
}));
import { GET } from '../status/route';
import { readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
import { getConfig } from '@/lib/config';
const mockGallery = readGalleryYaml as unknown as ReturnType<typeof vi.fn>;
const mockSettings = readSettingsYaml as unknown as ReturnType<typeof vi.fn>;
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
async function status() {
return (await GET()).json();
@@ -35,6 +45,11 @@ describe('GET /api/admin/status — config integrity', () => {
vi.clearAllMocks();
mockGallery.mockResolvedValue({ albums: ['a'] });
mockSettings.mockResolvedValue({ title: 'Site' });
mockConfig.mockReturnValue({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
});
});
it('reports a fully configured install as valid', async () => {
@@ -58,10 +73,76 @@ describe('GET /api/admin/status — config integrity', () => {
expect((await status()).config).toMatchObject({ status: 'invalid', settings: 'invalid' });
});
// gallery.yaml, unlike settings.yaml, is genuinely required.
it('reports a missing gallery.yaml as invalid', async () => {
/**
* gallery.yaml, unlike settings.yaml, is genuinely required — but a file that
* was never created is a pending setup step, not a corrupt config. Reporting
* both as "invalid" told a fresh deployment its config was broken and offered
* a backup restore as the cure (#507).
*/
it('reports a missing gallery.yaml as pending setup, not as invalid', async () => {
mockGallery.mockResolvedValue(null);
expect((await status()).config).toMatchObject({ status: 'setup', gallery: 'missing' });
});
it('still reports a gallery.yaml that cannot be parsed as invalid', async () => {
mockGallery.mockRejectedValue(new Error('bad indentation'));
expect((await status()).config).toMatchObject({ status: 'invalid', gallery: 'invalid' });
});
});
describe('GET /api/admin/status — setup state', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGallery.mockResolvedValue({ albums: ['a'] });
mockSettings.mockResolvedValue({ title: 'Site' });
});
it('reports a finished install as complete', async () => {
mockConfig.mockReturnValue({
needsSetup: false,
needsCredentials: false,
needsGallery: false,
});
expect((await status()).setup).toEqual({
complete: true,
credentials: 'present',
gallery: 'present',
});
});
/**
* The case from #507: credentials from the environment, no gallery.yaml. The
* admin panel must be able to tell those apart to say what is actually left
* to do instead of "System Degraded".
*/
it('separates missing credentials from a missing gallery.yaml', async () => {
mockConfig.mockReturnValue({
needsSetup: true,
needsCredentials: false,
needsGallery: true,
});
expect((await status()).setup).toEqual({
complete: false,
credentials: 'present',
gallery: 'missing',
});
});
it('reports absent credentials', async () => {
mockConfig.mockReturnValue({
needsSetup: true,
needsCredentials: true,
needsGallery: true,
});
expect((await status()).setup).toEqual({
complete: false,
credentials: 'missing',
gallery: 'missing',
});
});
});

View File

@@ -21,7 +21,10 @@ export async function GET(
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}

View File

@@ -5,6 +5,8 @@ import { getConfig } from '@/lib/config';
interface ImmichAlbumSummary {
id: string;
albumName: string;
/** Immich's own flag. Absent on older versions, which is not the same as false. */
shared?: boolean;
description: string;
albumThumbnailAssetId: string | null;
assetCount: number;
@@ -22,7 +24,10 @@ export async function GET() {
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}
@@ -51,6 +56,12 @@ export async function GET() {
createdAt: album.createdAt,
updatedAt: album.updatedAt,
isConfigured: configuredIds.has(album.id),
// Immich ignores ?shared=true and returns everything, so this list is the
// whole library rather than the shared part of it (#515). Passing the flag
// through lets the picker say which albums Immich still considers private
// — the accident-prevention the filter was supposed to give, without
// taking away the ability to publish them.
shared: album.shared,
}));
// Sort: configured first, then by name

View File

@@ -31,7 +31,10 @@ export async function GET(request: NextRequest) {
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
}

View File

@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'node:fs/promises';
import path from 'node:path';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import {
checkAlbumIds,
checkAlbumsShared,
checkAuthSecret,
checkImmichCalls,
checkPasswords,
checkProxyHops,
checkWritable,
countForwardedHops,
PROXY_MARKER_HEADERS,
worstLevel,
type AlbumRef,
type DoctorFinding,
type PasswordRef,
} from '@/lib/admin/doctor';
/**
* Config doctor (#491). Gathers the evidence; lib/admin/doctor.ts judges it.
*
* Everything is best-effort: a check that cannot gather its input reports that
* rather than failing the whole report, because a broken install is exactly
* when this route needs to answer.
*/
export async function GET(request: NextRequest) {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = getConfig();
const findings: DoctorFinding[] = [];
// ── Secret and proxy: read off the environment and this very request ──
findings.push(checkAuthSecret(env.AUTH_SECRET || config.authSecret));
findings.push(
checkProxyHops(
config.trustedProxyHops,
countForwardedHops(request.headers.get('x-forwarded-for')),
PROXY_MARKER_HEADERS.some((h) => !!request.headers.get(h)),
),
);
// ── Immich: the three calls Folio actually depends on ────────────────
const calls: Array<{ endpoint: string; ok: boolean }> = [];
let albums: AlbumRef[] = [];
if (config.needsCredentials) {
findings.push({
id: 'immich-api',
level: 'error',
title: 'No Immich URL or API key configured',
detail: 'Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install.',
});
} else {
const headers = { 'x-api-key': config.immich.apiKey, Accept: 'application/json' };
const call = async (endpoint: string, init?: RequestInit) => {
try {
const res = await fetch(`${config.immich.apiUrl}${endpoint}`, {
headers,
signal: AbortSignal.timeout(config.immichTimeoutMs),
...init,
});
calls.push({ endpoint, ok: res.ok });
return res.ok ? res : null;
} catch {
calls.push({ endpoint, ok: false });
return null;
}
};
await call('/server/ping');
const albumRes = await call('/albums');
await call('/search/metadata', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 1, page: 1 }),
});
if (albumRes) {
try {
albums = (await albumRes.json()) as AlbumRef[];
} catch {
// A malformed body is already reflected by the call above.
}
}
findings.push(checkImmichCalls(calls));
}
if (albums.length) {
findings.push(checkAlbumIds(config.albums, albums));
findings.push(checkAlbumsShared(config.albums, albums));
}
// ── Passwords: every place one can be configured ─────────────────────
const passwords: PasswordRef[] = [];
if (config.sitePassword) passwords.push({ label: 'Site password', value: config.sitePassword });
for (const sp of config.subpages) {
if (sp.password) passwords.push({ label: `Subpage ${sp.slug}`, value: sp.password });
}
for (const [slug, value] of Object.entries(config.albumPasswords)) {
if (value) passwords.push({ label: `Album ${slug}`, value });
}
try {
for (const entry of await listJournalEntries()) {
const password = entry.frontmatter.password;
if (password) passwords.push({ label: `Journal ${entry.slug}`, value: password });
}
} catch {
// Journal entries are optional; a missing directory is not a fault.
}
findings.push(checkPasswords(passwords));
// ── Writability of the content volume ────────────────────────────────
const contentDir = path.join(process.cwd(), 'content');
const unwritable: string[] = [];
for (const dir of ['', '.backups', 'journal']) {
const target = path.join(contentDir, dir);
try {
await fs.access(target, (await import('node:fs')).constants.W_OK);
} catch {
// A directory that does not exist yet is fine as long as its parent is
// writable — only report one that exists and refuses writes.
try {
await fs.stat(target);
unwritable.push(`content/${dir}`.replace(/\/$/, ''));
} catch {
// Not created yet.
}
}
}
findings.push(checkWritable(unwritable));
return NextResponse.json(
{ level: worstLevel(findings), findings },
{ headers: { 'Cache-Control': 'no-store' } },
);
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { cache } from '@/lib/cache';
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
@@ -22,6 +23,7 @@ export async function GET() {
// 2. Config Health Check
let galleryValid = false;
let galleryMissing = false;
// settings.yaml is optional — getConfig() falls back to defaults when it is
// absent, so a missing file is a valid installation, not a fault. Only a file
// that exists and cannot be parsed counts as degraded: readSettingsYaml()
@@ -31,6 +33,10 @@ export async function GET() {
try {
const gallery = await readGalleryYaml();
galleryValid = !!gallery;
// A file that was never created is a pending setup step, not a corrupt
// config. Reporting both as "invalid" told a fresh deployment its config
// was broken and offered a backup restore as the cure (#507).
galleryMissing = !gallery;
} catch (err) {
console.error('[Admin API] Gallery YAML parse failed:', err);
}
@@ -60,13 +66,20 @@ export async function GET() {
}
}
const config = getConfig();
return NextResponse.json({
immich: {
status: immichOk ? 'connected' : 'disconnected',
},
setup: {
complete: !config.needsSetup,
credentials: config.needsCredentials ? 'missing' : 'present',
gallery: config.needsGallery ? 'missing' : 'present',
},
config: {
status: galleryValid && settingsValid ? 'valid' : 'invalid',
gallery: galleryValid ? 'valid' : 'invalid',
status: galleryValid && settingsValid ? 'valid' : galleryMissing ? 'setup' : 'invalid',
gallery: galleryValid ? 'valid' : galleryMissing ? 'missing' : 'invalid',
settings: settingsValid ? 'valid' : 'invalid',
},
cache: {

View File

@@ -17,7 +17,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const config = getConfig();
if (config.needsSetup) {
// Credentials, not `needsSetup`: this route is how the operator picks the
// albums that gallery.yaml is built from, so refusing to run until that file
// exists is a deadlock (#507).
if (config.needsCredentials) {
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
}

View File

@@ -1,10 +1,13 @@
/**
* Auth API — validates album passwords and sets auth cookies.
* POST /api/auth { slug, password }
* Auth API — validates passwords and sets auth cookies.
* POST /api/auth { slug, password, type }
*
* `type: 'site'` unlocks the whole site; its `slug` is the fixed
* SITE_AUTH_KEY, since there is only ever one site-wide gate.
*/
import { NextRequest, NextResponse } from 'next/server';
import { authenticate, isProtected } from '@/lib/auth';
import { authenticate, isProtected, type ProtectedType } from '@/lib/auth';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Tight limit for auth attempts — 10 per minute per IP. */
@@ -46,13 +49,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
}
if (type !== 'subpage' && type !== 'album' && type !== 'journal') {
const TYPES: ProtectedType[] = ['subpage', 'album', 'journal', 'site'];
if (!TYPES.includes(type)) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
if (!isProtected(slug, type)) {
const typeLabel =
type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
type === 'journal'
? 'Journal entry'
: type === 'subpage'
? 'Subpage'
: type === 'site'
? 'Site'
: 'Album';
return NextResponse.json(
{
error: `${typeLabel} is not password-protected`,

View File

@@ -10,14 +10,16 @@ import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const config = getConfig();
// 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;
// Which groups this site publishes. The description used to be served
// unconditionally as editorial caption, which made it the one field an
// operator could not switch off — the problem behind #506. It is a group like
// any other now.
const show = config.exif;
// ── Rate limiting ──────────────────────────────────
const ip = getClientIp(request);
@@ -35,6 +37,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID
@@ -64,28 +71,38 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const exif = asset.exifInfo;
const caption = exif.description?.trim() || undefined;
return NextResponse.json(
{
...(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 } : {}),
const caption = show.caption ? exif.description?.trim() || undefined : undefined;
const payload = {
...(show.camera
? {
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
}
: {}),
...(show.settings
? {
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
}
: {}),
...(show.location ? { city: exif.city, country: exif.country } : {}),
...(caption ? { description: caption } : {}),
};
// Every group is switched off, or this asset carries nothing from the ones
// that are on. Say "no data" rather than answering with an empty object, which
// the lightbox would render as a blank panel.
if (Object.values(payload).every((value) => value === null || value === undefined)) {
return NextResponse.json({ error: 'No EXIF data' }, { status: 404 });
}
return NextResponse.json(payload, {
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
{
headers: {
'Cache-Control': 'private, max-age=86400, stale-while-revalidate=3600',
},
},
);
});
}

View File

@@ -1,10 +1,11 @@
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.
* The route serves the Immich asset description as a caption. It used to do so
* unconditionally — captions were treated as editorial rather than technical —
* which left it as the one field an operator could not hide (#506). Each group
* is now switchable, and `exifOnHover: false` keeps meaning "no technical EXIF"
* without touching captions.
*/
vi.mock('@/lib/config', () => ({
@@ -27,6 +28,8 @@ vi.mock('@/lib/rate-limit', () => ({
}));
import { GET } from '../[id]/route';
// Not from '@/lib/config' — that module is mocked above.
import { resolveExifDisplay, type ExifDisplayYaml } from '@/lib/config/schema';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import { NextRequest } from 'next/server';
@@ -55,6 +58,15 @@ function makeRequest() {
const params = { params: Promise.resolve({ id: 'token123' }) };
/** A config shaped the way getConfig() builds it, for the groups under test. */
function configWith(raw?: ExifDisplayYaml, exifOnHover?: boolean) {
return {
exifOnHover: exifOnHover !== false,
exif: resolveExifDisplay(raw, exifOnHover),
rateLimitRpm: 120,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockAssetInfo.mockResolvedValue(EXIF_ASSET);
@@ -62,7 +74,7 @@ beforeEach(() => {
describe('GET /api/exif/[id] captions', () => {
it('includes the trimmed description alongside technical fields', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith());
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
@@ -72,7 +84,7 @@ describe('GET /api/exif/[id] captions', () => {
});
it('serves only the caption when exifOnHover is disabled', async () => {
mockConfig.mockReturnValue({ exifOnHover: false, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith(undefined, false));
const res = await GET(makeRequest(), params);
expect(res.status).toBe(200);
@@ -83,7 +95,7 @@ describe('GET /api/exif/[id] captions', () => {
});
it('omits the description entirely when the asset has none', async () => {
mockConfig.mockReturnValue({ exifOnHover: true, rateLimitRpm: 120 });
mockConfig.mockReturnValue(configWith());
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, description: null },
});
@@ -92,4 +104,51 @@ describe('GET /api/exif/[id] captions', () => {
const body = await res.json();
expect(body).not.toHaveProperty('description');
});
it('drops the caption when the group is off', async () => {
mockConfig.mockReturnValue(configWith({ caption: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('description');
expect(body.model).toBe('X-T5');
});
it('drops the location without touching the camera', async () => {
mockConfig.mockReturnValue(configWith({ location: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('city');
expect(body).not.toHaveProperty('country');
expect(body.model).toBe('X-T5');
expect(body.iso).toBe(200);
});
it('drops the exposure settings without touching the camera', async () => {
mockConfig.mockReturnValue(configWith({ settings: false }));
const body = await (await GET(makeRequest(), params)).json();
expect(body).not.toHaveProperty('iso');
expect(body).not.toHaveProperty('fNumber');
expect(body.lensModel).toBe('XF 35mm');
});
/** An empty object would render as a blank panel rather than "no data". */
it('answers 404 when every group is switched off', async () => {
mockConfig.mockReturnValue(
configWith({ camera: false, settings: false, location: false, caption: false }),
);
const res = await GET(makeRequest(), params);
expect(res.status).toBe(404);
});
it('answers 404 when the enabled groups carry nothing for this asset', async () => {
mockConfig.mockReturnValue(configWith({ camera: false, settings: false, caption: false }));
mockAssetInfo.mockResolvedValue({
exifInfo: { ...EXIF_ASSET.exifInfo, city: null, country: null },
});
const res = await GET(makeRequest(), params);
expect(res.status).toBe(404);
});
});

View File

@@ -8,7 +8,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { immich } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { getConfigOrNull } from '@/lib/config';
const startTime = Date.now();
@@ -21,7 +22,11 @@ const CACHE_DURATION_MS = 10_000;
export async function GET(request: NextRequest) {
const ip = getClientIp(request);
const { rateLimitRpm } = getConfig();
// env, not getConfig(): a health check that cannot answer while the content
// config is unparsable is worse than useless — the Dockerfile probes this
// route, so a container with a broken gallery.yaml would restart in a loop at
// exactly the moment the app is still repairable through /admin (#519).
const rateLimitRpm = env.RATE_LIMIT_RPM;
const { success, remaining, resetAt } = checkRateLimit(`health:${ip}`, rateLimitRpm);
if (!success) {
@@ -38,6 +43,28 @@ export async function GET(request: NextRequest) {
);
}
/*
* A config that cannot be parsed is not a reason to kill the container.
* Restarting cannot fix a YAML typo, the app is still serving the setup
* screen, and /admin — where the fix happens — is still reachable. Reporting
* it as unhealthy made the Dockerfile's HEALTHCHECK restart the container in
* a loop at exactly the moment it was still repairable (#519).
*
* Immich itself is not consulted here: the ping reads the same config and
* would answer "unreachable" for a reason that has nothing to do with Immich.
*/
if (!getConfigOrNull()) {
return NextResponse.json(
{
status: 'setup',
config: 'invalid',
immich: 'unknown',
uptime: Math.floor((Date.now() - startTime) / 1000),
},
{ status: 200, headers: { 'Cache-Control': 'no-store' } },
);
}
const now = Date.now();
if (!cachedImmichOkPromise || now - lastCheckTime > CACHE_DURATION_MS) {
cachedImmichOkPromise = immich.ping();
@@ -56,6 +83,7 @@ export async function GET(request: NextRequest) {
const body = {
status: immichOk ? 'ok' : 'degraded',
config: 'ok',
immich: immichOk ? 'connected' : 'unreachable',
uptime: Math.floor((Date.now() - startTime) / 1000),
};

View File

@@ -13,6 +13,7 @@ import { resolveImageSize } from '@/lib/imageSize';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -38,6 +39,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID

View File

@@ -92,8 +92,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Could not reach Immich at this URL' }, { status: 502 });
}
// The covers of the albums just chosen, as the home page hero. One extra
// request during install, and it is the difference between a portfolio that
// opens with a photograph and one that opens with a list of names (#518).
const hero = albums.length ? await fetchAlbumCovers(apiBase, apiKey, albums) : [];
try {
await completeInstall({
hero,
apiUrl,
apiKey,
siteTitle: typeof body.siteTitle === 'string' ? body.siteTitle : undefined,
@@ -115,3 +121,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Failed to write configuration files' }, { status: 500 });
}
}
/** How many covers the home page carousel is seeded with. */
const HERO_SEED_MAX = 5;
/**
* Cover asset IDs for the given albums, in the order they were chosen.
*
* Best effort by design: this runs while the wizard is finishing, and a hero
* image is a nicety. Anything that goes wrong here leaves `hero: []`, which is
* exactly what the wizard wrote before.
*/
async function fetchAlbumCovers(
apiBase: string,
apiKey: string,
albumIds: string[],
): Promise<string[]> {
try {
const res = await fetch(`${apiBase}/albums`, {
headers: { 'x-api-key': apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) return [];
const all = (await res.json()) as Array<{ id: string; albumThumbnailAssetId: string | null }>;
const covers = new Map(all.map((a) => [a.id, a.albumThumbnailAssetId]));
return albumIds
.map((id) => covers.get(id))
.filter((assetId): assetId is string => !!assetId && UUID_REGEX.test(assetId))
.slice(0, HERO_SEED_MAX);
} catch {
return [];
}
}

View File

@@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls';
import { isAuthenticated } from '@/lib/auth';
import { isAuthenticated, siteLockResponse } from '@/lib/auth';
import { getMapData } from '@/lib/mapService';
import { ImmichUnavailableError } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -35,6 +35,11 @@ export async function GET(request: NextRequest) {
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const config = getConfig();
if (!config.map) {

View File

@@ -10,6 +10,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
/**
* OG image rendering runs satori + resvg per request and each unique ?title
@@ -34,6 +35,11 @@ export async function GET(request: NextRequest) {
});
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { searchParams } = request.nextUrl;
const title = (searchParams.get('title') || 'Gallery').slice(0, 200);
const subtitle = (searchParams.get('subtitle') || '').slice(0, 100);

View File

@@ -14,6 +14,7 @@ import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -32,6 +33,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
);
}
// The page-level gate does not cover route handlers — without this a locked
// site would still serve to anyone holding the URL.
const locked = siteLockResponse(request);
if (locked) return locked;
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import Link from 'next/link';
import { useDictionary } from '@/components/I18nProvider';
/**
* Rendered when a page throws — in practice almost always
@@ -22,22 +23,22 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useDictionary();
useEffect(() => {
console.error('[Folio] Page render failed:', error);
}, [error]);
return (
<div className="empty-state">
<h1 className="empty-state__title">Something went wrong</h1>
<p className="empty-state__text">
This page could not be loaded right now. It is usually temporary — try again in a moment.
</p>
<h1 className="empty-state__title">{t.error.errorTitle}</h1>
<p className="empty-state__text">{t.error.errorText}</p>
<div className="empty-state__actions">
<button type="button" onClick={reset} className="empty-state__button">
Try again
{t.error.tryAgain}
</button>
<Link href="/" className="empty-state__button empty-state__button--quiet">
Back to the gallery
{t.common.backToGallery}
</Link>
</div>
{error.digest && (
@@ -45,7 +46,7 @@ export default function Error({
className="empty-state__text"
style={{ marginTop: 24, fontSize: '0.8rem', opacity: 0.5 }}
>
Reference: {error.digest}
{t.error.reference(error.digest)}
</p>
)}
</div>

28
app/gate/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
/**
* The site-wide password gate.
*
* Nothing links here. `proxy.ts` rewrites every request to this route while
* the site is locked, which is what keeps the requested page from rendering
* at all — a gate in the layout would still let the page produce its RSC
* payload, album names and asset tokens included.
*/
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { isSiteLocked, SITE_AUTH_KEY } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default function GatePage() {
// Reachable on its own URL, so it has to answer for itself: with no site
// password configured there is no gate to show.
if (!isSiteLocked()) notFound();
return <PasswordGate slug={SITE_AUTH_KEY} title={getConfig().siteTitle} type="site" />;
}

97
app/global-error.tsx Normal file
View File

@@ -0,0 +1,97 @@
'use client';
import { useEffect } from 'react';
/**
* The last resort: rendered when the root layout itself throws, which
* `app/error.tsx` cannot catch — it is rendered *inside* that layout. The
* layout reads the config and the request headers, so this is not a theoretical
* case; without this file such a failure renders a blank page.
*
* Everything here is deliberately self-contained: it replaces the whole
* document, so `globals.css` and the theme custom properties are not
* guaranteed to be in scope. No config lookup, no `next/link` (there is no
* router shell to speak of), no shared components. Same wording as
* `app/error.tsx`, which explains why it does not name a cause: production
* builds replace the message with a digest, so the component genuinely cannot
* tell an Immich outage from anything else.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('[Folio] Root layout render failed:', error);
}, [error]);
return (
<html lang="en">
<body
style={{
margin: 0,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0c0c0c',
color: '#ededed',
colorScheme: 'dark',
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
padding: '24px',
}}
>
<main style={{ maxWidth: '32rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 500, margin: '0 0 12px' }}>
Something went wrong
</h1>
<p style={{ margin: '0 0 24px', lineHeight: 1.6, opacity: 0.75 }}>
This site could not be loaded right now. It is usually temporary — try again in a
moment.
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<button
type="button"
onClick={reset}
style={{
padding: '10px 20px',
border: '1px solid #ededed',
borderRadius: '4px',
background: 'transparent',
color: 'inherit',
font: 'inherit',
cursor: 'pointer',
}}
>
Try again
</button>
{/* A plain <a>, not next/link: a full document request is exactly
what is wanted here — a client-side transition would stay inside
the tree whose root layout just failed. */}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a
href="/"
style={{
padding: '10px 20px',
border: '1px solid transparent',
borderRadius: '4px',
color: 'inherit',
textDecoration: 'none',
opacity: 0.6,
}}
>
Back to the gallery
</a>
</div>
{error.digest && (
<p style={{ marginTop: '24px', fontSize: '0.8rem', opacity: 0.4 }}>
Reference: {error.digest}
</p>
)}
</main>
</body>
</html>
);
}

View File

@@ -820,10 +820,15 @@
width: 100%;
}
/* The album-cover grid. `--subpage-columns` comes from the resolved grid
config (global settings.yaml < per-subpage override); the fallback keeps the
historical two-up layout for anything rendering this grid without the vars.
`--subpage-gap` is a *preset* decision — the theme files below set it, and
only an explicit per-subpage `gap:` overrides it (inline vars win). */
.subpage-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
grid-template-columns: repeat(var(--subpage-columns, 2), 1fr);
gap: var(--subpage-gap, 4px);
}
.subpage-grid__item {
@@ -1043,6 +1048,13 @@
}
.photo-grid {
/* The gap the grid uses when settings.yaml does not set one. Declared rather
than left to each layout's own `var(--grid-gap, N)` fallback, because those
fallbacks disagree (6px filmstrip, 16px editorial-flow) and were dead code
while the variable was emitted unconditionally — 12px is what every layout
has actually rendered. A preset overrides it, an explicit setting beats
both by landing inline on this element (#513). */
--grid-gap: 12px;
column-count: var(--grid-columns, 3);
column-gap: var(--grid-gap, 12px);
}
@@ -1746,6 +1758,13 @@
.photo-grid--showcase {
grid-template-columns: repeat(2, 1fr);
}
/* Album covers follow the photo grids here: two-up on tablet widths, so a
configured 4 or 6 columns does not squeeze them below ~300px. The count is
computed server-side because repeat() does not reliably take a min(). */
.subpage-grid {
grid-template-columns: repeat(var(--subpage-columns-tablet, 2), 1fr);
}
}
@media (max-width: 640px) {

View File

@@ -7,17 +7,21 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
import './impressum.css';
export const metadata: Metadata = {
title: 'Impressum',
robots: { index: false, follow: true }, // Usually no need to index legal pages
};
export function generateMetadata(): Metadata {
return {
title: getServerDictionary().legal.title,
robots: { index: false, follow: true }, // Usually no need to index legal pages
};
}
export const dynamic = 'force-dynamic';
export default function ImpressumPage() {
const { legal } = getConfig();
const t = getServerDictionary();
if (!legal.enabled) {
notFound();
@@ -26,14 +30,14 @@ export default function ImpressumPage() {
return (
<div className="subpage-container">
<header className="subpage-header">
<BackLink href="/" label="Home" />
<h1 className="subpage-title">Impressum</h1>
<p className="subpage-subtitle">Angaben gemäß § 5 TMG</p>
<BackLink href="/" label={t.common.home} />
<h1 className="subpage-title">{t.legal.title}</h1>
<p className="subpage-subtitle">{t.legal.subtitle}</p>
</header>
<main className="subpage-content">
<section className="legal-section">
<h2 className="legal-section__title">Anschrift</h2>
<h2 className="legal-section__title">{t.legal.address}</h2>
<p className="legal-section__text">
{legal.name}
<br />
@@ -47,43 +51,51 @@ export default function ImpressumPage() {
{(legal.email || legal.phone) && (
<section className="legal-section">
<h2 className="legal-section__title">Kontakt</h2>
<h2 className="legal-section__title">{t.legal.contact}</h2>
<p className="legal-section__text">
{legal.email && (
<>
E-Mail: {legal.email}
{t.legal.email}: {legal.email}
<br />
</>
)}
{legal.phone && <>Telefon: {legal.phone}</>}
{legal.phone && (
<>
{t.legal.phone}: {legal.phone}
</>
)}
</p>
</section>
)}
{(legal.taxId || legal.vatId) && (
<section className="legal-section">
<h2 className="legal-section__title">Steuernummer</h2>
<h2 className="legal-section__title">{t.legal.taxSection}</h2>
<p className="legal-section__text">
{legal.taxId && (
<>
Steuernummer: {legal.taxId}
{t.legal.taxId}: {legal.taxId}
<br />
</>
)}
{legal.vatId && <>Umsatzsteuer-ID: {legal.vatId}</>}
{legal.vatId && (
<>
{t.legal.vatId}: {legal.vatId}
</>
)}
</p>
</section>
)}
{legal.extraInfo && (
<section className="legal-section">
<h2 className="legal-section__title">Weitere Informationen</h2>
<h2 className="legal-section__title">{t.legal.extraInfo}</h2>
<p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p>
</section>
)}
<section className="legal-source">
<p>Quelle: Erstellt mit dem Impressum-Generator von eRecht24.</p>
<p>{t.legal.source}</p>
</section>
</main>
</div>

View File

@@ -19,6 +19,7 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import type { ImmichAsset } from '@/lib/immich';
import PasswordGate from '@/components/PasswordGate';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
import crypto from 'crypto';
export const dynamic = 'force-dynamic';
@@ -30,11 +31,12 @@ interface JournalDetailPageProps {
export async function generateMetadata({ params }: JournalDetailPageProps): Promise<Metadata> {
const { slug } = await params;
const entry = await readJournalEntry(slug);
if (!entry) return { title: 'Not Found' };
const t = getServerDictionary();
if (!entry) return { title: t.journal.notFound };
const { frontmatter } = entry.parsed;
const title = frontmatter.title || slug;
const description = frontmatter.subtitle || 'Journal entry on Immich Folio';
const description = frontmatter.subtitle || t.journal.entryDescription;
const ogImages = frontmatter.coverAssetId
? [{ url: imageUrl(frontmatter.coverAssetId, 'preview') }]
@@ -171,7 +173,10 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = config.exifOnHover && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const exif =
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
? assetExifSummary(a)
: undefined;
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
@@ -188,7 +193,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
return (
<div style={{ paddingTop: '2rem' }}>
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '0 1.5rem 1rem' }}>
<BackLink href="/journal" label="Back to Journal" />
<BackLink href="/journal" label={getServerDictionary().common.backToJournal} />
</div>
<EssayView
essay={essayForClient}

View File

@@ -8,14 +8,15 @@ import { immich } from '@/lib/immich';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { BackLink } from '@/components/BackLink';
import { IconBook } from '@/components/Icons';
import { getServerDictionary } from '@/lib/i18n/server';
import './journal.css';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Journal',
description: 'Photo essays, travel stories, and behind-the-scenes journals.',
};
export function generateMetadata(): Metadata {
const t = getServerDictionary();
return { title: t.journal.title, description: t.journal.description };
}
interface EnrichedJournalEntry extends JournalEntrySummary {
coverUrl?: string;
@@ -24,6 +25,7 @@ interface EnrichedJournalEntry extends JournalEntrySummary {
}
export default async function JournalIndexPage() {
const t = getServerDictionary();
const isAuthedAdmin = await isAdminAuthenticated();
const allEntries = await listJournalEntries();
@@ -60,25 +62,23 @@ export default async function JournalIndexPage() {
return (
<div className="journal-index-container">
<div className="journal-index-header">
<BackLink href="/" label="Back to Gallery" />
<BackLink href="/" label={t.common.backToGallery} />
<p className="journal-index-header__kicker" aria-hidden="true">
Stories &amp; Essays
</p>
<h1 className="journal-index-header__title">Journal</h1>
<p className="journal-index-header__subtitle">
Photo essays, visual stories, and field notes.
{t.journal.kicker}
</p>
<h1 className="journal-index-header__title">{t.journal.title}</h1>
<p className="journal-index-header__subtitle">{t.journal.subtitle}</p>
</div>
{enrichedEntries.length === 0 ? (
<div className="journal-empty-state">
<p>No journal entries published yet.</p>
<p>{t.journal.empty}</p>
</div>
) : (
<div className="journal-grid">
{enrichedEntries.map((entry) => {
const dateStr = entry.frontmatter.date
? new Date(entry.frontmatter.date).toLocaleDateString('en-US', {
? new Date(entry.frontmatter.date).toLocaleDateString(t.dateLocale, {
year: 'numeric',
month: 'short',
day: 'numeric',
@@ -113,9 +113,11 @@ export default async function JournalIndexPage() {
<div className="journal-card__meta">
{dateStr && <span>{dateStr}</span>}
{dateStr && entry.readingTimeMinutes && <span>•</span>}
{entry.readingTimeMinutes && <span>{entry.readingTimeMinutes} min read</span>}
{entry.readingTimeMinutes && (
<span>{t.journal.minRead(entry.readingTimeMinutes)}</span>
)}
{entry.frontmatter.draft && (
<span className="journal-card__draft-badge">Draft</span>
<span className="journal-card__draft-badge">{t.journal.draft}</span>
)}
</div>
@@ -134,9 +136,11 @@ export default async function JournalIndexPage() {
)}
<div className="journal-card__footer">
<span className="journal-card__read-more">Read Story →</span>
<span className="journal-card__read-more">{t.journal.readStory}</span>
{entry.frontmatter.author && (
<span style={{ opacity: 0.65 }}>by {entry.frontmatter.author}</span>
<span style={{ opacity: 0.65 }}>
{t.journal.by(entry.frontmatter.author)}
</span>
)}
</div>
</div>

View File

@@ -5,7 +5,7 @@
*/
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { cookies, headers } from 'next/headers';
import Link from 'next/link';
import './globals.css';
import { SubpageNav } from '@/components/SubpageNav';
@@ -16,10 +16,13 @@ import { SetupScreen } from '@/components/SetupScreen';
import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
import { isAdminPath } from '@/lib/admin/paths';
import { isInstallPath } from '@/lib/install';
import { isSiteLocked, isSiteUnlocked } from '@/lib/auth';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
import AssetProtection from '@/components/AssetProtection';
import AnalyticsTracker from '@/components/AnalyticsTracker';
import { I18nProvider } from '@/components/I18nProvider';
import { resolveLocale, getDictionary } from '@/lib/i18n';
export async function generateMetadata(): Promise<Metadata> {
const config = getConfigOrNull();
@@ -34,9 +37,13 @@ export async function generateMetadata(): Promise<Metadata> {
const siteTitle = config.seo.title;
const siteDescription = config.seo.description;
// A site behind a password has nothing to offer a crawler, and the SEO
// toggles are about a *public* site — so the lock overrides them rather
// than depending on the operator also remembering to set noIndex.
const locked = isSiteLocked();
const robots = {
index: !config.seo.noIndex,
follow: !config.seo.noFollow,
index: !locked && !config.seo.noIndex,
follow: !locked && !config.seo.noFollow,
};
return {
@@ -114,6 +121,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
}
const isAdmin = isAdminPath(pathname);
const locale = resolveLocale(config.lang);
const t = getDictionary(locale);
/*
* Whether this render is the site-wide gate. The gate itself is a page
* (`app/gate/page.tsx`) that `proxy.ts` rewrites to; all the layout does is
* strip the chrome around it, because a header listing every subpage would
* give away the shape of a site that is supposed to be shut.
*/
const cookieStore = await cookies();
const siteGated =
!isAdmin && !isInstallPath(pathname) && !isSiteUnlocked((name) => cookieStore.get(name)?.value);
return (
<html
@@ -125,6 +144,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
data-header-dot={String(theme.headerDot)}
data-photo-frame={theme.photoFrame}
data-transitions={String(config.transitions)}
/* The configured starting mode is rendered server-side so the first paint
is already right; `auto` deliberately carries no data-theme and lets the
inline script below decide from the OS. data-default-theme is what
ThemeToggle reads for a visitor who has never chosen (#512). */
{...(config.colorMode === 'auto' ? {} : { 'data-theme': config.colorMode })}
data-default-theme={config.colorMode}
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -132,49 +157,51 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="stylesheet" href={fontsUrl} />
</head>
<body>
{isAdmin ? (
children
) : (
<>
<a href="#main-content" className="skip-link">
Skip to content
</a>
<header className="header">
<nav className="header__nav">
{/* Brand wordmark — presets that show it pair it with the dot. */}
<span className="header__wordmark" aria-hidden="true">
{config.siteTitle}
</span>
<Link href="/" className="header__nav-link">
Home
</Link>
<SubpageNav />
{config.aboutEnabled && (
<Link href="/about" className="header__nav-link">
About
<I18nProvider locale={locale}>
{siteGated || isAdmin ? (
children
) : (
<>
<a href="#main-content" className="skip-link">
{t.nav.skipToContent}
</a>
<header className="header">
<nav className="header__nav">
{/* Brand wordmark — presets that show it pair it with the dot. */}
<span className="header__wordmark" aria-hidden="true">
{config.siteTitle}
</span>
<Link href="/" className="header__nav-link">
{t.nav.home}
</Link>
)}
{config.map && (
<Link href="/map" className="header__nav-link">
Map
</Link>
)}
<ThemeToggle />
</nav>
</header>
<main id="main-content" tabIndex={-1} className="main">
{children}
</main>
<Footer />
{config.scrollToTop && <ScrollToTop />}
<AssetProtection
disableRightClick={config.protection?.disableRightClick}
disableImageDrag={config.protection?.disableImageDrag}
/>
<AnalyticsTracker />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
</>
)}
<SubpageNav />
{config.aboutEnabled && (
<Link href="/about" className="header__nav-link">
{t.nav.about}
</Link>
)}
{config.map && (
<Link href="/map" className="header__nav-link">
{t.nav.map}
</Link>
)}
<ThemeToggle />
</nav>
</header>
<main id="main-content" tabIndex={-1} className="main">
{children}
</main>
<Footer />
{config.scrollToTop && <ScrollToTop />}
<AssetProtection
disableRightClick={config.protection?.disableRightClick}
disableImageDrag={config.protection?.disableImageDrag}
/>
<AnalyticsTracker />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
</>
)}
</I18nProvider>
</body>
</html>
);

View File

@@ -3,10 +3,12 @@
*/
import styles from './loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {
const t = getServerDictionary();
return (
<div className={styles.grid} role="status" aria-label="Loading gallery">
<div className={styles.grid} role="status" aria-label={t.common.loadingGallery}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}

View File

@@ -8,16 +8,18 @@ import { notFound } from 'next/navigation';
import { MapView } from '@/components/MapView';
import { BackLink } from '@/components/BackLink';
import type { Metadata } from 'next';
import { getServerDictionary } from '@/lib/i18n/server';
import './map.css';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Map',
};
export function generateMetadata(): Metadata {
return { title: getServerDictionary().map.title };
}
export default function MapPage() {
const config = getConfig();
const t = getServerDictionary();
if (!config.map) {
notFound();
@@ -42,16 +44,15 @@ export default function MapPage() {
<div className="map-page">
<div className="map-page__header">
<div className="map-page__header-main">
<BackLink href="/" label="Back to Gallery" />
<BackLink href="/" label={t.common.backToGallery} />
<p className="map-page__kicker" aria-hidden="true">
Where
{t.map.kicker}
</p>
<h1 className="map-page__title">Map</h1>
<p className="map-page__subtitle">Where the photos were taken</p>
<h1 className="map-page__title">{t.map.title}</h1>
<p className="map-page__subtitle">{t.map.subtitle}</p>
</div>
<p className="map-page__meta" aria-hidden="true">
{collectionCount} {collectionCount === 1 ? 'collection' : 'collections'} · {albumCount}{' '}
{albumCount === 1 ? 'album' : 'albums'}
{t.common.collections(collectionCount)} · {t.common.albums(albumCount)}
</p>
</div>
<MapView />

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
/**
* Rendered when a page calls notFound() — i.e. Immich answered and the album
@@ -10,15 +11,14 @@ import Link from 'next/link';
* crawler) that their content is gone.
*/
export default function NotFound() {
const t = getServerDictionary();
return (
<div className="empty-state">
<h1 className="empty-state__title">Not found</h1>
<p className="empty-state__text">
This page does not exist, or the album is no longer published.
</p>
<h1 className="empty-state__title">{t.error.notFoundTitle}</h1>
<p className="empty-state__text">{t.error.notFoundText}</p>
<div className="empty-state__actions">
<Link href="/" className="empty-state__button">
Back to the gallery
{t.common.backToGallery}
</Link>
</div>
</div>

View File

@@ -11,6 +11,8 @@ import { getConfig } from '@/lib/config';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { HeroCarousel } from '@/components/HeroCarousel';
import { FadeIn } from '@/components/FadeIn';
import { getServerDictionary } from '@/lib/i18n/server';
import type { Dictionary } from '@/lib/i18n';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -19,25 +21,24 @@ type HeroSubpage = { slug: string; name: string; albumCount: number };
type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number };
const pad2 = (n: number) => String(n).padStart(2, '0');
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
/**
* Flatten subpages + standalone albums into one indexed nav list.
* Every entry carries an index and a count; presets decide what to show.
*/
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[]) {
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Dictionary) {
return [
...subpages.map((sp) => ({
key: `sp-${sp.slug}`,
href: `/${sp.slug}`,
label: sp.name,
count: plural(sp.albumCount, 'album'),
count: t.common.albums(sp.albumCount),
})),
...albums.map((a) => ({
key: `al-${a.id}`,
href: `/${a.slug}`,
label: a.albumName,
count: plural(a.assetCount, 'photo'),
count: t.common.photos(a.assetCount),
})),
];
}
@@ -61,9 +62,10 @@ function heroExifLine(asset: ImmichAsset): string | undefined {
/** Shared nav links for hero sections. */
function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) {
const t = getServerDictionary();
return (
<>
{heroNavEntries(subpages, albums).map((entry, i) => (
{heroNavEntries(subpages, albums, t).map((entry, i) => (
<Link key={entry.key} href={entry.href} className="hero__nav-link">
<span className="hero__nav-index" aria-hidden="true">
{pad2(i + 1)}
@@ -111,6 +113,7 @@ function HeroTextContent({
export default async function HomePage() {
const config = getConfig();
const t = getServerDictionary();
if (config.needsSetup) {
return null;
@@ -141,7 +144,7 @@ export default async function HomePage() {
// 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 entries = heroNavEntries(subpages, albums, t);
const enterHref = entries[0]?.href ?? '/about';
return (
@@ -158,7 +161,7 @@ export default async function HomePage() {
)}
<FadeIn delay={250}>
<Link href={enterHref} className="hero__cover-enter">
Enter
{t.home.enter}
<span className="hero__cover-enter-arrow" aria-hidden="true">
→
</span>
@@ -197,7 +200,7 @@ export default async function HomePage() {
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
if (heroStyle === 'stacked') {
const allEntries = heroNavEntries(subpages, albums);
const allEntries = heroNavEntries(subpages, albums, t);
return (
<div className="hero hero--stacked">

View File

@@ -54,7 +54,8 @@
/* Photo grid: generous spacing, cinematic transitions */
[data-preset='editorial'] .photo-grid {
column-gap: 32px;
/* Preset default only — an explicit grid.gap lands inline and wins (#513). */
--grid-gap: 32px;
}
[data-preset='editorial'] .photo-grid__item {
@@ -161,7 +162,7 @@
/* Subpage grid */
[data-preset='editorial'] .subpage-grid {
gap: 2px;
--subpage-gap: 2px;
}
[data-preset='editorial'] .subpage-grid__item {

View File

@@ -90,7 +90,11 @@
/* Photo grid: zero hover effects, clinical stillness */
[data-preset='minimal'] .photo-grid {
column-gap: 2px;
/* The preset's own spacing, and only its default: an explicit grid.gap lands
inline on this element and wins. It used to be a hardcoded column-gap, which
no setting could override and which left the row spacing on the global 12px
while the columns stayed at 2px (#513). */
--grid-gap: 2px;
}
[data-preset='minimal'] .photo-grid__item {
@@ -179,7 +183,7 @@
/* Subpage grid */
[data-preset='minimal'] .subpage-grid {
gap: 2px;
--subpage-gap: 2px;
}
[data-preset='minimal'] .subpage-grid__item {

View File

@@ -30,7 +30,8 @@
/* Photo grid: numbered indices with CSS counter */
[data-preset='monograph'] .photo-grid {
counter-reset: photo-index;
column-gap: 40px;
/* Preset default only — an explicit grid.gap lands inline and wins (#513). */
--grid-gap: 40px;
}
[data-preset='monograph'] .photo-grid__item {
@@ -170,7 +171,7 @@
/* Subpage grid */
[data-preset='monograph'] .subpage-grid {
gap: 1px;
--subpage-gap: 1px;
}
[data-preset='monograph'] .subpage-grid__item {

View File

@@ -615,8 +615,7 @@
/* ── Subpage: Album-Cover mit Caption-Bar ───────────────────── */
[data-preset='studio-modern'] .subpage-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
--subpage-gap: 20px;
}
[data-preset='studio-modern'] .subpage-grid__item {