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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
131
app/admin/components/DoctorModal.tsx
Normal file
131
app/admin/components/DoctorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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's own spacing. Tablet widths
|
||||
show at most two covers per row.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
||||
|
||||
@@ -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><html lang></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 & 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 & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & 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 & 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 & 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 & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & 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 & 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 & 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>
|
||||
|
||||
71
app/api/__tests__/health-config.test.ts
Normal file
71
app/api/__tests__/health-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
137
app/api/__tests__/site-gate.test.ts
Normal file
137
app/api/__tests__/site-gate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
91
app/api/admin/__tests__/doctor-guards.test.ts
Normal file
91
app/api/admin/__tests__/doctor-guards.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
147
app/api/admin/doctor/route.ts
Normal file
147
app/api/admin/doctor/route.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
28
app/gate/page.tsx
Normal 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
97
app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
117
app/layout.tsx
117
app/layout.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
app/page.tsx
19
app/page.tsx
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user