release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -1,8 +1,14 @@
/**
* Instant shell for the homepage.
*
* It lives in the (home) route group, not in app/, on purpose. A loading.tsx
* in app/ wraps every route below the root layout in a Suspense boundary, and
* a page inside one streams its 200 before it can call notFound() — which made
* every unknown slug the catch-all answers a soft 404. See
* app/[...path]/page.tsx.
*/
import styles from './loading.module.css';
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {

View File

@@ -118,7 +118,7 @@ export default async function HomePage() {
const ph = asset ? assetPlaceholder(asset) : null;
const exif = asset ? await heroExifLine(asset, config.exif) : undefined;
return {
src: imageUrl(id, 'preview'),
src: imageUrl(asset ?? id, 'preview'),
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
...(exif ? { exif } : {}),
};

View File

@@ -14,7 +14,7 @@ import { essayPhotoSequence } from '@/lib/essaySequence';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
import { formatJournalDate } from '@/lib/journalDate';
import { formatJournalDate, journalDateTime } from '@/lib/journalDate';
/**
* The map block is the only thing in an essay that needs Leaflet, and most
@@ -145,7 +145,7 @@ function EssayViewContent({
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
}}
>
@@ -253,7 +253,7 @@ function EssayViewContent({
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
}}
>
@@ -409,16 +409,19 @@ function EssayViewContent({
// The story's photos that are selected, not the whole stored set (see PhotoGrid).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
const dateText = formatJournalDate(essay.frontmatter.date, t.dateLocale);
const dateTime = journalDateTime(essay.frontmatter.date);
return (
<div className="essay-container">
<header className="essay-header">
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
{(essay.frontmatter.author || essay.frontmatter.date) && (
{(essay.frontmatter.author || dateText) && (
<div className="essay-header__meta">
{[essay.frontmatter.author, formatJournalDate(essay.frontmatter.date, t.dateLocale)]
.filter(Boolean)
.join(' • ')}
{essay.frontmatter.author}
{essay.frontmatter.author && dateText && ' • '}
{dateText && (dateTime ? <time dateTime={dateTime}>{dateText}</time> : dateText)}
</div>
)}
</header>
@@ -465,7 +468,7 @@ function EssayViewContent({
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',

View File

@@ -0,0 +1,28 @@
/**
* Placeholder for album and subpage content while Immich answers. These routes
* are force-dynamic and wait on a live Immich round-trip — up to
* IMMICH_TIMEOUT_MS when the server is struggling — so without this the viewer
* stares at a blank page.
*
* This used to be app/[...path]/loading.tsx. It is a Suspense fallback inside
* the page now, placed after the page has decided whether the path exists: a
* loading.tsx wraps the whole page, so the skeleton went out with a 200 before
* the page could call notFound().
*/
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export function PathSkeleton() {
const t = getServerDictionary();
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}
</div>
</>
);
}

View File

@@ -20,6 +20,9 @@ import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
import { justifiedTileStyle } from '@/lib/justifiedRow';
const pad2 = (n: number) => String(n).padStart(2, '0');
export interface PhotoItem {
id: string;
@@ -40,6 +43,14 @@ export interface PhotoItem {
caption?: string;
/** Present only when the surrounding album offers its originals (#475). */
downloadUrl?: string;
/**
* The full-resolution image for the lightbox zoom, fetched only when the
* visitor zooms (#467). Present only where zoom resolves on and the photo
* has a zoomable source; the size is the image's pixel size, upright.
*/
zoomUrl?: string;
zoomWidth?: number;
zoomHeight?: number;
}
interface PhotoGridProps {
@@ -114,11 +125,25 @@ function PhotoGridInner({
setLightboxIndex(byToken);
return;
}
// A `photo` that names no photo in this album — mistyped, truncated by a
// messenger, or a position like `?photo=5` (that was never a format) —
// opens nothing, so it should not stay in the address bar to be copied
// and shared on. Checked against the whole album, not `displayedAssets`:
// a real photo that the "selected only" filter hides is still a valid link.
if (token !== null && !assets.some((a) => a.id === token)) {
window.history.replaceState(
null,
'',
window.location.pathname +
buildPhotoQuery(window.location.search, null) +
window.location.hash,
);
}
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx);
}
}, [displayedAssets]);
}, [assets, displayedAssets]);
// ── Sync the URL's `photo` param when the lightbox state changes ──
useEffect(() => {
@@ -211,25 +236,31 @@ function PhotoGridInner({
// Keyboard navigation and the scroll lock live in <Lightbox/>.
// A work's catalogue number on the wall label is its place in the album,
// not in the current view: the "selected only" filter must not renumber it.
const albumPosition = useMemo(() => new Map(assets.map((a, i) => [a.id, i + 1])), [assets]);
const gridItems = useMemo(() => {
return displayedAssets.map((asset, index) => {
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
// A focal length alone is not a label, as before.
const exifLine =
asset.camera || asset.lens
? [asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')
: '';
// Justified rows: the flex sizing must sit on the outermost grid child,
// which is the FadeIn wrapper — not the .photo-grid__item inside it.
// Aspect ratio drives both grow factor and basis; ~3:2 fallback for
// assets without dimensions (e.g. videos) keeps the row math sane.
const justifiedAr = asset.aspectRatio || 1.5;
const justifiedStyle: React.CSSProperties | undefined =
layout === 'justified'
? {
flexGrow: justifiedAr,
flexBasis: `calc(var(--grid-row-height, 300px) * ${justifiedAr})`,
}
: undefined;
layout === 'justified' ? justifiedTileStyle(asset.aspectRatio) : undefined;
return (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0} style={justifiedStyle}>
<FadeIn
key={asset.id}
as="figure"
delay={index < 12 ? index * 50 : 0}
style={justifiedStyle}
>
<div
className={`photo-grid__item${
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
@@ -247,23 +278,30 @@ function PhotoGridInner({
aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` }
: {}),
position: 'relative',
}}
>
<Image
src={asset.thumbUrl}
alt={asset.caption ?? ''}
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
{/* The photo's own box. Without a frame it covers the whole tile;
under passepartout globals.css insets it by the mat, so `fill`
and the placeholder colour stay on the photo, not on the mat. */}
<div
className="photo-grid__media"
style={asset.dominantColor ? { backgroundColor: asset.dominantColor } : undefined}
>
<Image
src={asset.thumbUrl}
alt={asset.caption ?? ''}
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
</div>
{proofing && (
<button
@@ -288,7 +326,7 @@ function PhotoGridInner({
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
padding: 0,
}}
@@ -314,16 +352,35 @@ function PhotoGridInner({
</svg>
</div>
)}
{(asset.camera || asset.lens) && (
{exifLine && (
<div className="photo-grid__item-exif" aria-hidden="true">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
{exifLine}
</div>
)}
</div>
{/* The wall label under the photo, outside the tile and its clip.
globals.css hides it; a preset that hangs its photos as works
(kunsthalle) shows it. It carries only what the site already
publishes for the photo: its number, the caption that is the
image's alt text (absent unless the `caption` EXIF group is on),
and the EXIF line. The number and EXIF repeat the tile's own
label and the lightbox, so they stay out of the figure's name. */}
<figcaption className="photo-grid__label">
<span className="photo-grid__label-no" aria-hidden="true">
{pad2(albumPosition.get(asset.id) ?? index + 1)}
</span>
{asset.caption && <span className="photo-grid__label-title">{asset.caption}</span>}
{exifLine && (
<span className="photo-grid__label-exif" aria-hidden="true">
{exifLine}
</span>
)}
</figcaption>
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing, t]);
}, [albumPosition, displayedAssets, layout, openLightbox, proofing, t]);
// Photos of this album that are selected. Not `favorites.size`: the stored set
// can also hold a photo since removed, or a favourite from another album whose
@@ -368,7 +425,7 @@ function PhotoGridInner({
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
@@ -386,7 +443,7 @@ function PhotoGridInner({
color: proofing.isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
borderRadius: 'var(--radius-md)',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import { imageUrl } from '@/lib/urls';
import { imageUrl, type ImageRef } from '@/lib/urls';
import { SubpageSectionConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { BackLink } from '@/components/BackLink';
@@ -11,6 +11,8 @@ interface SubpageAlbum {
albumName: string;
assetCount: number;
albumThumbnailAssetId: string | null;
/** The cover asset as Immich has it, so an edited cover gets its edit marker (#831). */
cover?: ImageRef;
/** EXPERIMENTAL: focal point for the cover crop (CSS object-position) */
coverPosition?: string;
}
@@ -73,7 +75,7 @@ function AlbumGrid({
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
src={imageUrl(album.cover ?? album.albumThumbnailAssetId, 'preview')}
alt=""
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Suspense, isValidElement, type ReactElement } from 'react';
import fs from 'node:fs';
import path from 'node:path';
/**
* Next sends the status with the first byte, and the first byte goes out as
* soon as a Suspense fallback renders. The catch-all used to sit inside
* loading.tsx boundaries (app/ and app/[...path]/), so its skeleton streamed
* as a 200 before the page could call notFound(): `/nope`, an offline subpage,
* a draft content page and `/<subpage>/<unknown>` were all soft 404s — the
* not-found page with a 200.
*
* The page now decides whether the path exists before it renders a boundary,
* and only then streams the skeleton around the part that fetches photos.
*/
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
vi.mock('next/navigation', () => ({
notFound: () => {
throw NOT_FOUND;
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
const admin = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => admin.signedIn }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
vi.mock('@/lib/auth', () => ({
isProtected: (key: string) => locked.keys.has(key),
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const pages = vi.hoisted(() => ({
access: new Map<string, 'open' | 'draft' | 'locked'>(),
}));
vi.mock('../contentPage', () => ({
contentPageAccess: vi.fn(async (slug: string) => pages.access.get(slug) ?? null),
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
const ICELAND = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [],
albumThumbnailAssetId: null,
};
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn(),
getSubpageAlbums: vi.fn(),
findAlbumBySlug: vi.fn(),
getAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
}));
vi.mock('@/lib/immich', () => ({ immich }));
import PathPage from '../page';
import { PathSkeleton } from '../PathSkeleton';
const call = (segments: string[]) => ({
params: Promise.resolve({ path: segments }),
searchParams: Promise.resolve({}),
});
beforeEach(() => {
vi.clearAllMocks();
admin.signedIn = false;
locked.keys.clear();
pages.access = new Map([
['about-us', 'open'],
['pricing', 'draft'],
['members', 'locked'],
]);
// `dubai` is `enabled: false`: isSubpageSlug() and every lookup skip it.
immich.isSubpageSlug.mockImplementation((slug: string) =>
['travel', 'vault', 'empty'].includes(slug),
);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
if (slug === 'empty') return { subpage: { slug, name: 'Empty', albumIds: [] }, albums: [] };
if (slug === 'travel' || slug === 'vault') {
return { subpage: { slug, name: slug, albumIds: ['album-1'] }, albums: [ICELAND] };
}
return null;
});
immich.findAlbumBySlug.mockImplementation(async (slug: string, subpage?: string) =>
slug === 'iceland' && (subpage === undefined || subpage === 'travel' || subpage === 'vault')
? ICELAND
: null,
);
immich.getAlbumBySlug.mockResolvedValue(ICELAND);
});
describe('catch-all not-found status', () => {
it.each([
['an unknown slug', ['nope']],
['a long made-up slug', ['this-is-a-long-made-up-slug-that-names-nothing-at-all-0123456789']],
['a disabled subpage', ['dubai']],
['a draft content page', ['pricing']],
['a subpage without published albums', ['empty']],
['an unknown album under a real subpage', ['travel', 'nope']],
['an album under a disabled subpage', ['dubai', 'iceland']],
['a three-segment path', ['x', 'y', 'z']],
])('calls notFound() for %s before anything can stream', async (_, segments) => {
await expect(PathPage(call(segments))).rejects.toBe(NOT_FOUND);
// No photo was fetched: the decision came from the album list alone.
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it.each([
['a subpage', ['travel']],
['an album under a subpage', ['travel', 'iceland']],
['a standalone album', ['iceland']],
['a content page', ['about-us']],
['a locked content page (its gate renders)', ['members']],
])('streams %s behind the skeleton, deciding first', async (_, segments) => {
const element = (await PathPage(call(segments))) as ReactElement<{ fallback: ReactElement }>;
expect(isValidElement(element)).toBe(true);
expect(element.type).toBe(Suspense);
expect(element.props.fallback.type).toBe(PathSkeleton);
// The asset fetch — the slow part — happens inside the boundary.
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it('does not tell a visitor without the password which albums a locked subpage holds', async () => {
locked.keys.add('vault');
const known = await PathPage(call(['vault', 'iceland']));
const unknown = await PathPage(call(['vault', 'no-such-album']));
expect((known as ReactElement).type).toBe(Suspense);
expect((unknown as ReactElement).type).toBe(Suspense);
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
});
it('lets a locked subpage show its gate even before its albums are known', async () => {
locked.keys.add('empty');
const element = await PathPage(call(['empty']));
expect((element as ReactElement).type).toBe(Suspense);
});
it('leaves a signed-in admin the diagnostic banner instead of the 404', async () => {
admin.signedIn = true;
const element = await PathPage(call(['nope']));
expect((element as ReactElement).type).toBe(Suspense);
// ...but never for a path deeper than the catch-all serves.
await expect(PathPage(call(['x', 'y', 'z']))).rejects.toBe(NOT_FOUND);
});
it('surfaces an Immich outage as an error, never as a missing album', async () => {
const outage = new Error('Immich unavailable');
immich.findAlbumBySlug.mockRejectedValue(outage);
await expect(PathPage(call(['iceland']))).rejects.toBe(outage);
await expect(PathPage(call(['travel', 'iceland']))).rejects.toBe(outage);
});
it('has no loading.tsx above it, which would stream the 200 first', () => {
// A loading file wraps every route below its folder in a Suspense
// boundary, and nothing the page does inside one can change the status.
const appDir = path.join(process.cwd(), 'app');
for (const dir of [appDir, path.join(appDir, '[...path]')]) {
const loading = fs.readdirSync(dir).filter((name) => /^loading\.(t|j)sx?$/.test(name));
expect(loading, dir).toEqual([]);
}
});
});

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ReactElement } from 'react';
/**
* The catch-all answers one segment (subpage, content page, standalone album)
@@ -24,6 +25,7 @@ const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn((slug: string) => slug === 'travel'),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
findAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
@@ -49,6 +51,7 @@ const call = (path: string[]) => ({
beforeEach(() => {
vi.clearAllMocks();
immich.getAlbumBySlug.mockResolvedValue(ALBUM);
immich.findAlbumBySlug.mockResolvedValue(ALBUM);
immich.getSubpageAlbums.mockResolvedValue({
subpage: { slug: 'travel', name: 'Travel', albumIds: ['album-1', 'album-2'] },
albums: [ALBUM, { ...ALBUM, id: 'album-2', slug: 'norway', albumName: 'Norway' }],
@@ -61,6 +64,7 @@ describe('catch-all path depth', () => {
async (path) => {
await expect(PathPage(call(path))).rejects.toBe(NOT_FOUND);
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
expect(immich.getSubpageAlbums).not.toHaveBeenCalled();
},
);
@@ -72,7 +76,13 @@ describe('catch-all path depth', () => {
});
it('still renders one and two segments', async () => {
await expect(PathPage(call(['iceland']))).resolves.toBeTruthy();
await expect(PathPage(call(['travel', 'iceland']))).resolves.toBeTruthy();
for (const path of [['iceland'], ['travel', 'iceland']]) {
// The page decides, then hands the rendering to the content inside its
// Suspense boundary; run that too.
const shell = (await PathPage(call(path))) as ReactElement<{ children: ReactElement }>;
const content = shell.props.children as ReactElement<object>;
const render = content.type as (props: object) => Promise<unknown>;
await expect(render(content.props)).resolves.toBeTruthy();
}
});
});

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* A `?photo=` that names no photo in the album opened nothing and stayed in
* the address bar, to be copied and shared on (P-16). It is removed now; a
* valid deep link still opens its photo.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('?photo= on load', () => {
it('opens the photo a valid token names', () => {
window.history.replaceState(null, '', '/wedding?photo=a2');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(window.location.search).toBe('?photo=a2');
});
it('drops a token that names no photo, keeping other parameters', () => {
window.history.replaceState(null, '', '/wedding?preview=true&photo=bogus');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.location.pathname + window.location.search).toBe('/wedding?preview=true');
});
it('drops a position-style ?photo=5, which was never a format', () => {
window.history.replaceState(null, '', '/wedding?photo=5');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.location.search).toBe('');
});
it('still honours a legacy #photo-N hash next to a bad token', () => {
window.history.replaceState(null, '', '/wedding?photo=bogus#photo-3');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a3');
});
});

View File

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* Kunsthalle's wall label was the EXIF overlay with a CSS-counter number in
* front of it, so a photo without camera or lens got no label and no number.
* Each photo is now a <figure> with a figcaption that always carries its
* number; globals.css hides it outside the presets that show it.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({
children,
as: Wrapper = 'div',
}: {
children: React.ReactNode;
as?: 'div' | 'figure';
}) => <Wrapper>{children}</Wrapper>,
}));
const photo = (id: string, extra: Partial<PhotoItem> = {}): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
...extra,
});
const ASSETS = [
photo('a1', { camera: 'Leica M11', lens: 'Summicron 35', focalLength: '35mm' }),
photo('a2'),
photo('a3', { caption: 'Cloister at dusk' }),
photo('a4', { focalLength: '50mm' }),
];
let store: Map<string, string>;
beforeEach(() => {
store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.history.replaceState(null, '', '/kloster');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const labels = (container: HTMLElement) =>
[...container.querySelectorAll('figure > figcaption.photo-grid__label')] as HTMLElement[];
describe('photo wall label', () => {
it('numbers every photo, with or without EXIF', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const numbers = labels(container).map(
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
);
expect(numbers).toEqual(['01', '02', '03', '04']);
});
it('sits next to the tile, not inside its clipped box', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
for (const label of labels(container)) {
expect(label.closest('.photo-grid__item')).toBeNull();
expect(label.previousElementSibling?.classList.contains('photo-grid__item')).toBe(true);
}
});
it('carries the caption only where one is published, and names the figure with it', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const titles = labels(container).map(
(l) => l.querySelector('.photo-grid__label-title')?.textContent ?? null,
);
expect(titles).toEqual([null, null, 'Cloister at dusk', null]);
// The same text the photo already publishes as its alt text.
expect(container.querySelector('img[alt="Cloister at dusk"]')).not.toBeNull();
// What assistive tech reads off the label (the figure's name, by HTML-AAM)
// is the caption alone.
const spoken = (l: HTMLElement) =>
[...l.children]
.filter((c) => c.getAttribute('aria-hidden') !== 'true')
.map((c) => c.textContent)
.join('');
expect(labels(container).map(spoken)).toEqual(['', '', 'Cloister at dusk', '']);
});
it('prints the EXIF line only for a camera or lens, as the tile does', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const exif = labels(container).map(
(l) => l.querySelector('.photo-grid__label-exif')?.textContent ?? null,
);
expect(exif).toEqual(['Leica M11 · Summicron 35 · 35mm', null, null, null]);
});
it('keeps the number and EXIF, which repeat the tile and lightbox, out of the figure name', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const [first] = labels(container);
expect(first.querySelector('.photo-grid__label-no')?.getAttribute('aria-hidden')).toBe('true');
expect(first.querySelector('.photo-grid__label-exif')?.getAttribute('aria-hidden')).toBe(
'true',
);
});
it('keeps a photo’s album number when the favourites filter hides the others', async () => {
store.set('folio_fav_kloster', JSON.stringify(['a3']));
const { container } = render(<PhotoGrid assets={ASSETS} proofing albumName="Kloster" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
const numbers = labels(container).map(
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
);
expect(numbers).toEqual(['03']);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import type { ImmichAsset } from '@/lib/immich';
/**
* #831: a photo edited in Immich (crop, rotate) reaches the grid, the
* lightbox and the zoom as edited — an image URL with the edit marker, the
* edited proportions, and the edited full-size rendition behind the zoom.
*/
vi.mock('@/lib/config', () => ({
getConfig: () => ({ authSecret: 'test-auth-secret-32-chars-long-min' }),
getConfigOrNull: () => null,
}));
const { toPhotoItems } = await import('../photoItems');
const exif = (w: number, h: number, orientation: string | null = null) =>
({ exifImageWidth: w, exifImageHeight: h, orientation }) as ImmichAsset['exifInfo'];
const asset = (over: Partial<ImmichAsset>): ImmichAsset => ({
id: '11111111-1111-1111-1111-111111111111',
type: 'IMAGE',
originalFileName: 'scan.jpg',
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: '2020-07-24T11:13:27.000Z',
isTrashed: false,
...over,
});
const ALBUM = '22222222-2222-2222-2222-222222222222';
describe('toPhotoItems for photos edited in Immich (#831)', () => {
// Measured on Immich 3.2: a scan stored 1076×723, rotated 270° in Immich.
const rotated = asset({
isEdited: true,
updatedAt: '2026-09-04T19:22:09.220Z',
width: 723,
height: 1076,
exifInfo: exif(1076, 723),
});
it('marks the edited photo’s URLs and gives it the edited proportions', () => {
const [item] = toPhotoItems([rotated], false, false);
expect(item.thumbUrl).toMatch(/[?&]e=[0-9a-z]+(&|$)/);
expect(item.previewUrl).toBe(item.thumbUrl);
expect(item.aspectRatio).toBeCloseTo(723 / 1076);
});
it('leaves a photo that is not edited exactly as it was', () => {
const [item] = toPhotoItems(
[asset({ width: 1076, height: 723, exifInfo: exif(1076, 723) })],
false,
false,
);
expect(item.thumbUrl).toMatch(/\?size=preview$/);
expect(item.aspectRatio).toBeCloseTo(1076 / 723);
});
it('offers zoom at the edited size', () => {
const [item] = toPhotoItems([rotated], false, false, undefined, ALBUM);
expect(item.zoomUrl).toMatch(/^\/api\/zoom\//);
expect([item.zoomWidth, item.zoomHeight]).toEqual([723, 1076]);
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* Two gaps in a subpage's `<head>`:
*
* - A `hidden: true` subpage is reachable by link only — off the nav, the
* home page and the sitemap — yet it and its albums inherited the layout's
* `index, follow`, so a crawler that found the link indexed it anyway.
* - A subpage with more than one album had no description of its own and fell
* back to the site-wide one; an album page describes itself by its photo
* count.
*/
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
vi.mock('@/lib/ogImage', () => ({ ogImageUrl: () => '/api/og?sig=x' }));
vi.mock('../contentPage', () => ({
contentPageAccess: vi.fn(async () => null),
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
vi.mock('@/lib/auth', () => ({
isProtected: () => false,
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const config = vi.hoisted(() => ({
subpages: [] as { slug: string; hidden?: boolean }[],
seo: { noFollow: false },
}));
vi.mock('@/lib/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/config')>();
return { ...actual, getConfig: () => config };
});
const album = (id: string, name: string) => ({
id,
albumName: name,
slug: name.toLowerCase(),
assets: [{ id: `${id}-a`, type: 'IMAGE' }],
albumThumbnailAssetId: null,
});
const ICELAND = album('album-1', 'Iceland');
const NORWAY = album('album-2', 'Norway');
const subpages: Record<string, { subtitle?: string; albums: ReturnType<typeof album>[] }> = {
travel: { albums: [ICELAND, NORWAY] },
trips: { subtitle: 'Where the road went', albums: [ICELAND, NORWAY] },
private: { albums: [ICELAND, NORWAY] },
handover: { albums: [ICELAND] },
};
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn(),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
}));
vi.mock('@/lib/immich', () => ({ immich }));
import { generateMetadata } from '../page';
const meta = (path: string[]) =>
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
beforeEach(() => {
config.subpages = [
{ slug: 'travel' },
{ slug: 'trips' },
{ slug: 'private', hidden: true },
{ slug: 'handover', hidden: true },
];
config.seo.noFollow = false;
immich.isSubpageSlug.mockImplementation((slug: string) => slug in subpages);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
const sp = subpages[slug];
return sp ? { subpage: { slug, name: slug, subtitle: sp.subtitle }, albums: sp.albums } : null;
});
immich.getAlbumBySlug.mockImplementation(
async (slug: string) => [ICELAND, NORWAY].find((a) => a.slug === slug) ?? null,
);
});
describe('hidden subpages stay out of search results', () => {
it.each([
['a hidden multi-album subpage', ['private']],
['a hidden single-album subpage', ['handover']],
['an album below a hidden subpage', ['private', 'iceland']],
])('marks %s noindex', async (_, path) => {
const m = await meta(path);
expect(m.robots).toEqual({ index: false, follow: true });
});
it('keeps the site-wide nofollow on a hidden subpage', async () => {
config.seo.noFollow = true;
expect((await meta(['private'])).robots).toEqual({ index: false, follow: false });
});
it('leaves a visible subpage, its albums and standalone albums to the layout', async () => {
expect((await meta(['travel'])).robots).toBeUndefined();
expect((await meta(['travel', 'iceland'])).robots).toBeUndefined();
expect((await meta(['iceland'])).robots).toBeUndefined();
});
});
describe('multi-album subpage description', () => {
it('describes the subpage by its album count when it has no subtitle', async () => {
const m = await meta(['travel']);
expect(m.description).toBe('travel — 2 albums');
expect(m.openGraph?.description).toBe('travel — 2 albums');
});
it('prefers the subtitle when there is one', async () => {
const m = await meta(['trips']);
expect(m.description).toBe('Where the road went');
});
});

View File

@@ -20,7 +20,7 @@ import PasswordGate from '@/components/PasswordGate';
import { EssayView } from './EssayView';
import { buildEssayPayload } from './essayPayload';
type Access = 'open' | 'draft' | 'locked';
export type Access = 'open' | 'draft' | 'locked';
async function accessFor(page: PageRecord): Promise<Access> {
const { draft, password } = page.parsed.frontmatter;
@@ -34,6 +34,16 @@ async function accessFor(page: PageRecord): Promise<Access> {
return 'open';
}
/**
* How this request may see the page `slug`, or null when there is none. Read
* by routeExists() before the catch-all streams: a draft is a 404 to everyone
* but an admin, and that has to be known while the status can still say so.
*/
export async function contentPageAccess(slug: string): Promise<Access | null> {
const page = await readPage(slug).catch(() => null);
return page ? accessFor(page) : null;
}
/**
* Metadata for `/<slug>` when it is a page, or null when it is not.
*

View File

@@ -178,8 +178,8 @@ export async function buildEssayPayload(
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
thumbUrl: imageUrl(a, 'preview'),
previewUrl: imageUrl(a, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),

View File

@@ -1,22 +0,0 @@
/**
* Instant shell for album and subpage routes. These are force-dynamic and wait
* on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is
* struggling — so without this the viewer stares at a blank page.
*/
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={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}
</div>
</>
);
}

View File

@@ -13,6 +13,7 @@
* Three or more segments: 404.
*/
import { Suspense } from 'react';
import { cookies } from 'next/headers';
import type { Metadata } from 'next';
import { immich, type ImmichAsset } from '@/lib/immich';
@@ -28,6 +29,7 @@ import {
hasExifPanelContent,
normalizeSlug,
resolveProofing,
resolveZoom,
type GridConfig,
} from '@/lib/config';
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
@@ -49,6 +51,8 @@ import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
import { getServerDictionary } from '@/lib/i18n/server';
import { contentPageMetadata, renderContentPage } from './contentPage';
import { loadAlbumBlocks } from './essayPayload';
import { isLocked, routeExists } from './routeExists';
import { PathSkeleton } from './PathSkeleton';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -58,20 +62,6 @@ interface PathPageProps {
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
}
/**
* Whether `key` is password-protected and this request has not unlocked it.
* generateMetadata runs unconditionally — unlike the page body, nothing
* downstream of it stops a real title, photo count or cover image from
* reaching an unauthenticated `<head>` unless this is checked first
* (GHSA-fvgv-97g3-wjr7).
*/
async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
if (!isProtected(key, type)) return false;
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
return !isAuthenticated(key, getCookie, type);
}
/**
* Metadata for a path that names nothing this site publishes. The title is
* the dictionary's, never the requested slug: generateMetadata runs for any
@@ -111,15 +101,17 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
// Each branch follows the page body's order of gates and 404s, and takes
// the title only from site-authored text (an album or subpage name). A
// branch that finds nothing, or finds it locked, returns early, so the
// requested slug never becomes a title or share-card text.
// requested slug never becomes a title or share-card text. isLocked() comes
// first because generateMetadata runs unconditionally: nothing downstream
// stops a real title, photo count or cover image from reaching an
// unauthenticated `<head>` otherwise (GHSA-fvgv-97g3-wjr7).
const slug = path[0];
const t = getServerDictionary();
let title: string;
let subtitle = '';
let description: string | undefined = undefined;
const photoCount = (assets: ImmichAsset[]) =>
getServerDictionary().common.photos(
assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length,
);
t.common.photos(assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length);
if (path.length === 1 && immich.isSubpageSlug(slug)) {
if (await isLocked(slug, 'subpage')) return lockedMetadata();
@@ -135,6 +127,9 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
title = result.subpage.title || result.subpage.name;
// A cover grid has no photo count to describe it by; without this it
// inherited the site-wide description.
description ??= `${title} — ${t.common.albums(result.albums.length)}`;
}
} else if (path.length === 2) {
if (await isLocked(slug, 'subpage')) return lockedMetadata();
@@ -162,11 +157,19 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
photoAsset?.exifInfo?.description?.trim() ||
description ||
(subtitle ? `${title} — ${subtitle}` : undefined);
const ogImage = photoAsset ? imageUrl(photoAsset.id, 'preview') : ogImageUrl(title, subtitle);
const ogImage = photoAsset ? imageUrl(photoAsset, 'preview') : ogImageUrl(title, subtitle);
// A hidden subpage is reachable by link only: out of the nav, the home page
// and the sitemap. Indexing it, or an album below it, would undo that.
const config = getConfig();
const hidden =
(path.length === 2 || immich.isSubpageSlug(slug)) &&
config.subpages.some((sp) => sp.slug === slug && sp.hidden === true);
return {
title,
description: ogDescription,
...(hidden ? { robots: { index: false, follow: !config.seo.noFollow } } : {}),
openGraph: {
title,
description: ogDescription,
@@ -187,13 +190,22 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
* claim without one (#472).
*/
function structuredDataFor(
album: { albumName: string; description?: string; albumThumbnailAssetId?: string | null },
album: {
albumName: string;
description?: string;
albumThumbnailAssetId?: string | null;
assets?: ImmichAsset[];
},
path: string,
photoCount: number,
) {
const config = getConfig();
const cover = album.albumThumbnailAssetId
? absoluteUrl(config.siteUrl, imageUrl(album.albumThumbnailAssetId, 'preview'))
const coverId = album.albumThumbnailAssetId;
// The album's own copy of its cover, when it has one, so an edit made in
// Immich reaches the URL (#831).
const coverAsset = coverId ? album.assets?.find((a) => a.id === coverId) : undefined;
const cover = coverId
? absoluteUrl(config.siteUrl, imageUrl(coverAsset ?? coverId, 'preview'))
: null;
return albumStructuredData({
siteUrl: config.siteUrl,
@@ -241,12 +253,39 @@ async function getAlbumHeroData(
const asset = await immich.getAssetInfo(heroAssetId);
const ph = asset ? assetPlaceholder(asset) : null;
return {
heroImageUrl: imageUrl(heroAssetId, 'preview'),
heroImageUrl: imageUrl(asset ?? heroAssetId, 'preview'),
heroBlurDataURL: ph?.blurDataURL,
};
}
export default async function PathPage({ params, searchParams }: PathPageProps) {
/**
* Decides whether the path exists before anything streams, then renders the
* content behind the skeleton.
*
* The order is the point. Next sends the status with the first byte, and the
* first byte goes out as soon as a Suspense fallback renders. A notFound()
* thrown after that can only add a noindex tag to a 200 (a "soft 404"), which
* is what every unknown slug got while this page sat inside a loading.tsx
* boundary. routeExists() needs only the config and the album list the header
* nav already waited for, so the check costs no time; the album's photos, the
* slow part, still load behind the skeleton.
*
* PathContent keeps its own notFound() calls as the backstop for what the
* check cannot see, such as an album removed from Immich since the list was
* cached. Those are still soft.
*/
export default async function PathPage(props: PathPageProps) {
const { path: rawPath } = await props.params;
if (!(await routeExists(rawPath?.map(normalizeSlug)))) notFound();
return (
<Suspense fallback={<PathSkeleton />}>
<PathContent {...props} />
</Suspense>
);
}
async function PathContent({ params, searchParams }: PathPageProps) {
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
@@ -299,6 +338,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// reached without a subpage follow the global setting.
const proofingFor = (subpage?: { proofing?: boolean }) =>
resolveProofing(subpage, config.proofing.enabled);
// Lightbox zoom (#467): album, then the subpage it is shown on, then the
// site setting. The album id goes to toPhotoItems() when it resolves on.
const zoomAlbumFor = (albumId: string, subpage?: { zoom?: boolean }) =>
resolveZoom(config, albumId, subpage) ? albumId : undefined;
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
// the precedence is global < subpage < album.
@@ -352,6 +395,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
zoomAlbumFor(album.id, subpageData?.subpage),
);
// Password gate for protected albums
@@ -575,6 +619,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
zoomAlbumFor(album.id, result.subpage),
);
// Password gate for protected albums
@@ -619,14 +664,23 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
};
});
// Batch-fetch ThumbHash for album cover placeholders
const coverPlaceholders = await Promise.all(
albumsWithHero.map(async (album) => {
if (!album.albumThumbnailAssetId) return null;
const asset = await immich.getAssetInfo(album.albumThumbnailAssetId);
return asset ? assetPlaceholder(asset) : null;
}),
// Batch-fetch the cover assets: ThumbHash for the placeholders, and
// whether the cover was edited in Immich, which its URL has to say (#831).
const coverAssets = await Promise.all(
albumsWithHero.map((album) =>
album.albumThumbnailAssetId ? immich.getAssetInfo(album.albumThumbnailAssetId) : null,
),
);
const coverPlaceholders = coverAssets.map((asset) => (asset ? assetPlaceholder(asset) : null));
const albumsWithCover = albumsWithHero.map((album, i) => {
const asset = coverAssets[i];
return asset
? {
...album,
cover: { id: asset.id, isEdited: asset.isEdited, updatedAt: asset.updatedAt },
}
: album;
});
// 1-based position among the enabled subpages — drives the header kicker.
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
@@ -646,7 +700,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
slug={slug}
title={result.subpage.title || result.subpage.name}
subtitle={result.subpage.subtitle}
albums={albumsWithHero}
albums={albumsWithCover}
coverPlaceholders={coverPlaceholders}
sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
@@ -687,6 +741,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
zoomAlbumFor(album.id),
);
// Password gate for protected albums

View File

@@ -16,7 +16,9 @@ import {
exifUrl,
imageUrl,
videoUrl,
zoomUrl,
} from '@/lib/urls';
import { zoomDimensions, zoomSourceFor } from '@/lib/zoomSource';
import type { PhotoItem } from './PhotoGrid';
/**
@@ -32,6 +34,11 @@ export function toPhotoItems(
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
/**
* The album whose page offers lightbox zoom, or undefined when zoom resolves
* off for it (#467). Each photo still has to be zoomable on its own.
*/
zoomAlbumId?: string,
): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
@@ -40,17 +47,25 @@ export function toPhotoItems(
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO';
const zoomSize = zoomAlbumId && zoomSourceFor(a) ? zoomDimensions(a) : undefined;
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
thumbUrl: imageUrl(a, 'preview'),
previewUrl: imageUrl(a, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
...(zoomAlbumId && zoomSize
? {
zoomUrl: zoomUrl(zoomAlbumId, a.id),
zoomWidth: zoomSize.width,
zoomHeight: zoomSize.height,
}
: {}),
aspectRatio: assetAspectRatio(a),
};
});

View File

@@ -0,0 +1,70 @@
/**
* Whether the catch-all has anything to show for a path — asked before the
* page streams, so that a miss can still answer with a real 404.
*
* Next commits to the status with the first byte. The page used to sit inside
* a loading.tsx Suspense boundary, so the skeleton went out as a 200 and the
* notFound() that followed could only add a noindex tag: every unknown slug, an
* offline subpage, a draft content page and `/<subpage>/<unknown>` were soft
* 404s. The page now calls this first, outside any boundary, and only then
* streams the skeleton around the part that fetches photos.
*
* Cost: it reads the config, the album list and content/pages/. The album list
* is the one the header nav already waits for before the first byte, so this
* adds no Immich request and no latency; the album's assets — the slow part —
* are still fetched behind the skeleton.
*
* It mirrors the order of gates and 404s in the page body, and may be looser
* than the body, never stricter. A path it passes that the body then rejects
* only loses the status (an album that vanished from Immich between the list
* and the asset fetch stays a soft 404); a path it rejects that the body would
* render is a page nobody can open.
*
* Errors propagate. An Immich outage with no stale list to fall back on must
* surface as an error, never as "this album does not exist".
*/
import { cookies } from 'next/headers';
import { immich } from '@/lib/immich';
import { isAuthenticated, isProtected } from '@/lib/auth';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import { contentPageAccess } from './contentPage';
/** Whether `key` is password-protected and this request has not unlocked it. */
export async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
if (!isProtected(key, type)) return false;
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
return !isAuthenticated(key, getCookie, type);
}
/** `path` is already slug-normalised, as the page hands it over. */
export async function routeExists(path: readonly string[] | undefined): Promise<boolean> {
// One segment or two: nothing on this site lives deeper, for anyone.
if (!path || path.length === 0 || path.length > 2) return false;
if (await exists(path)) return true;
// Where a visitor gets the 404, a signed-in admin gets a diagnostic banner
// (or the draft content page).
return isAdminAuthenticated();
}
async function exists([first, second]: readonly string[]): Promise<boolean> {
if (second !== undefined) {
// A locked subpage shows its gate for any album slug below it. Answering
// 404 for the unknown ones would tell a visitor without the password which
// albums are behind it.
if (await isLocked(first, 'subpage')) return true;
return (await immich.findAlbumBySlug(second, first)) !== null;
}
if (immich.isSubpageSlug(first)) {
if (await isLocked(first, 'subpage')) return true;
const result = await immich.getSubpageAlbums(first);
return !!result && result.albums.length > 0;
}
const page = await contentPageAccess(first);
if (page) return page !== 'draft';
return (await immich.findAlbumBySlug(first)) !== null;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
/**
* A locked site still serves /impressum and /privacy (proxy.ts, UNGATED_PAGES),
* and the gate is where a visitor who cannot get in finds them. A link to a
* page that is switched off would lead to a 404, so each appears only when its
* page exists.
*/
const config = {
siteTitle: 'Test Folio',
lang: 'en',
legal: { enabled: true },
privacy: { enabled: true },
};
let privacy = true;
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
}));
vi.mock('@/lib/auth', () => ({
isSiteLocked: () => true,
SITE_AUTH_KEY: '__site__',
}));
vi.mock('@/lib/privacy', () => ({
privacyAvailable: () => privacy,
}));
import GatePage from '../gate/page';
const html = () => renderToStaticMarkup(<GatePage />);
describe('site gate page', () => {
beforeEach(() => {
config.lang = 'en';
config.legal.enabled = true;
privacy = true;
});
it('links the legal notice and the privacy policy', () => {
const out = html();
expect(out).toContain('href="/impressum"');
expect(out).toContain('href="/privacy"');
expect(out).toContain('aria-label="Legal information"');
});
it('leaves out a page that is switched off', () => {
config.legal.enabled = false;
expect(html()).not.toContain('href="/impressum"');
expect(html()).toContain('href="/privacy"');
config.legal.enabled = true;
privacy = false;
expect(html()).toContain('href="/impressum"');
expect(html()).not.toContain('href="/privacy"');
});
it('renders no empty link list when neither page exists', () => {
config.legal.enabled = false;
privacy = false;
expect(html()).not.toContain('<nav');
});
it('labels the links in the site language', () => {
config.lang = 'de';
const out = html();
expect(out).toContain('>Impressum</a>');
expect(out).toContain('>Datenschutz</a>');
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from 'vitest';
/**
* A 404 decided before streaming — by proxy.ts, or by a page calling
* notFound() outside any Suspense boundary — is rendered from app/not-found.tsx
* inside the root layout, and Next takes its metadata from the layouts and
* that file, never from the page. Without metadata of its own the tab showed
* the bare site title, and the layout's `index, follow` sat next to Next's
* `noindex`; the soft-404 path meanwhile read "Not found".
*/
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('de') };
});
import * as notFoundModule from '../not-found';
import { getDictionary } from '@/lib/i18n';
describe('app/not-found.tsx metadata', () => {
it('titles a hard 404 from the dictionary and drops the layout robots tag', async () => {
const { generateMetadata } = notFoundModule as { generateMetadata?: () => unknown };
expect(typeof generateMetadata).toBe('function');
expect(await generateMetadata!()).toEqual({
title: getDictionary('de').error.notFoundTitle,
robots: null,
});
});
});

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* P-17: /about shared with no picture. A page's openGraph replaces the
* layout's whole, so leaving `images` out dropped the site card too.
*/
let aboutMd = '';
vi.mock('fs', async (orig) => {
const actual = await orig<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
readFile: async (path: string, enc: string) =>
String(path).endsWith('about.md')
? aboutMd
: actual.promises.readFile(path, enc as 'utf-8'),
},
};
});
vi.mock('@/lib/config', () => ({
getConfig: () => ({ authSecret: 'test-secret', aboutEnabled: true }),
getConfigOrNull: () => ({ lang: 'en' }),
}));
vi.mock('@/lib/urls', () => ({
imageUrl: (ref: string | { id: string; isEdited?: boolean }, size: string) =>
typeof ref === 'string'
? `/api/image/tok-${ref}?size=${size}`
: `/api/image/tok-${ref.id}?size=${size}${ref.isEdited ? '&e=edit' : ''}`,
assetPlaceholder: () => null,
}));
const portraitAsset = vi.hoisted(() => ({ value: null as null | Record<string, unknown> }));
vi.mock('@/lib/immich', () => ({
immich: { getAssetInfo: async () => portraitAsset.value },
}));
const { generateMetadata } = await import('../page');
describe('/about metadata', () => {
beforeEach(() => {
aboutMd = '';
portraitAsset.value = null;
});
it('shares a portrait edited in Immich under its edited URL (#831)', async () => {
aboutMd = '---\nname: Ada\nportrait: portrait-uuid\n---\nHello.';
portraitAsset.value = { id: 'portrait-uuid', isEdited: true };
const m = await generateMetadata();
expect(m.openGraph?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview&e=edit']);
});
it('uses the portrait as the share image', async () => {
aboutMd = '---\nname: Ada\nportrait: portrait-uuid\n---\nHello.';
const m = await generateMetadata();
expect(m.openGraph?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview']);
expect(m.twitter?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview']);
});
it('falls back to the generated card without a portrait', async () => {
aboutMd = '---\nname: Ada\n---\nHello.';
const m = await generateMetadata();
const [image] = m.openGraph?.images as string[];
expect(image).toMatch(/^\/api\/og\?/);
expect(new URL(image, 'https://x.test').searchParams.get('title')).toBe(m.title);
});
});

View File

@@ -9,6 +9,7 @@ import yaml from 'js-yaml';
import Image from 'next/image';
import type { Metadata } from 'next';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { ogImageUrl } from '@/lib/ogImage';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
@@ -56,6 +57,17 @@ export async function generateMetadata(): Promise<Metadata> {
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;
// A page's openGraph replaces the layout's whole, images included, so
// leaving them out shared /about with no picture at all. The portrait is the
// obvious one (it is a published asset); without it, the generated card.
// The portrait's asset for its edit marker (#831), from the cache the page
// body reads as well.
const portraitAsset = meta.portrait
? await immich.getAssetInfo(meta.portrait).catch(() => null)
: null;
const ogImage = meta.portrait
? imageUrl(portraitAsset ?? meta.portrait, 'preview')
: ogImageUrl(title);
return {
title,
@@ -63,11 +75,13 @@ export async function generateMetadata(): Promise<Metadata> {
openGraph: {
title,
description,
images: [ogImage],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
@@ -89,7 +103,7 @@ export default async function AboutPage() {
<div className="about__portrait-col">
{meta.portrait ? (
<Image
src={imageUrl(meta.portrait, 'preview')}
src={imageUrl(portraitAsset ?? meta.portrait, 'preview')}
alt={meta.name || t.about.portraitAlt}
className="about__portrait"
fill

View File

@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { DndContext } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
import { SortablePageRow, SortableHeroTile } from '../components/page-builder/SortableTiles';
import { dndAnnouncements } from '../components/dndAnnouncements';
afterEach(cleanup);
/**
* QA A-20: the sortable attributes (role="button", tabindex) sat on the whole
* row, which also holds the row's own button — nested interactive content. They
* belong on the drag handle. And the drag announcements read out internal ids.
*/
describe('sortable rows', () => {
it('put the sortable role on the handle, not around the row button', () => {
render(
<DndContext>
<SortableContext items={['page-pricing']}>
<SortablePageRow id="page-pricing" title="Pricing" isActive={false} onClick={() => {}} />
</SortableContext>
</DndContext>,
);
const handle = screen.getByLabelText('Reorder Pricing');
expect(handle.getAttribute('role')).toBe('button');
for (const button of screen.getAllByRole('button')) {
expect(button.parentElement?.closest('[role="button"]')).toBeNull();
}
});
it('keeps the hero tile remove button out of the handle', () => {
render(
<DndContext>
<SortableContext items={['hero-0']}>
<SortableHeroTile id="abc" index={0} onRemove={() => {}} />
</SortableContext>
</DndContext>,
);
const remove = screen.getByTitle('Remove');
expect(remove.parentElement?.closest('[role="button"]')).toBeNull();
expect(screen.getByLabelText('Reorder hero photo 1').getAttribute('role')).toBe('button');
});
});
describe('dndAnnouncements', () => {
const label = (id: string | number) => (id === 'page-pricing' ? 'page "Pricing"' : 'page "FAQ"');
const a = dndAnnouncements(label);
const active = { id: 'page-pricing' } as never;
const over = { id: 'subpage-3' } as never;
it('speaks the names the list shows, not the ids', () => {
expect(a.onDragStart({ active })).toBe('Picked up page "Pricing".');
expect(a.onDragEnd({ active, over } as never)).toBe(
'Page "Pricing" was dropped at the position of page "FAQ".',
);
expect(a.onDragCancel({ active, over: null } as never)).toBe(
'Moving page "Pricing" was cancelled.',
);
});
});

View File

@@ -35,6 +35,7 @@ const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObj
assetOrder: ['33333333-3333-3333-3333-333333333333'],
coverPosition: '50% 25%',
download: true,
zoom: false,
location: 'hidden',
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
};
@@ -47,6 +48,17 @@ describe('album entry round trip', () => {
expect(serialized).toEqual({ [uuid]: everyOption });
});
it('keeps zoom: false, which switches zoom off for the album (#467)', () => {
const parsed = parseAlbumEntries([{ [uuid]: { zoom: false } }]);
expect(parsed[0].zoom).toBe(false);
expect(hasAlbumOptions(parsed[0])).toBe(true);
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { zoom: false } }]);
});
it('writes no zoom key while the album inherits', () => {
expect(serializeAlbumEntries([{ id: uuid, zoom: undefined }])).toEqual([uuid]);
});
it('keeps map precision across a save', () => {
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);

View File

@@ -68,7 +68,7 @@ describe('hero, subpages and sections', () => {
it('names a new subpage after its position and starts it empty', () => {
const g = ops.addSubpage(state());
expect(g.subpages[3]).toEqual({ name: 'New Page 4', albums: [], sections: undefined });
expect(g.subpages[3]).toEqual({ name: 'New subpage 4', albums: [], sections: undefined });
});
it('adds, updates and removes sections', () => {

View File

@@ -138,6 +138,7 @@ const everySubpageKey: Required<SubpageYaml> = {
sections: [{ title: 'North', description: 'Cold', albums: [B] }],
password: 'scrypt:aa:bb',
proofing: false,
zoom: true,
essayFile: 'story',
essayText: 'Text',
enabled: false,
@@ -161,6 +162,18 @@ describe('subpage keys', () => {
});
});
describe('subpage zoom (#467)', () => {
it('keeps zoom in either direction, and writes nothing when unset', () => {
const off = { name: 'Z', zoom: false, albums: [A] };
const on = { name: 'Z', zoom: true, albums: [A] };
expect(roundTrip({ subpages: [off] })).toEqual({ subpages: [off] });
expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] });
expect(roundTrip({ subpages: [{ name: 'Z', albums: [A] }] })).toEqual({
subpages: [{ name: 'Z', albums: [A] }],
});
});
});
describe('content page references (#722)', () => {
it('keeps `- page:` entries where they were among the subpages', () => {
const yaml = {

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { sameDraft } from '../components/sameDraft';
/**
* "Unsaved changes" stayed up after an edit was typed back to the saved value
* (QA A-19): the editors only ever set their dirty flag. They now also compare
* with the saved state, which has to see through what an undone edit leaves.
*/
describe('sameDraft', () => {
it('is false for a real change', () => {
expect(sameDraft({ title: 'A' }, { title: 'B' })).toBe(false);
expect(sameDraft({ navLinks: [{ label: 'A' }] }, { navLinks: [] })).toBe(false);
expect(sameDraft({ albums: ['a', 'b'] }, { albums: ['b', 'a'] })).toBe(false);
});
it('ignores key order — a deleted key typed again moves to the end', () => {
expect(
sameDraft(
{ theme: { accent: '#fff', preset: 'noir' } },
{
theme: { preset: 'noir', accent: '#fff' },
},
),
).toBe(true);
});
it('ignores the empty parent objects a settings edit creates', () => {
expect(sameDraft({ title: 'A', contact: {} }, { title: 'A' })).toBe(true);
expect(sameDraft({ theme: { fonts: {} } }, {})).toBe(true);
});
it('ignores undefined, which the YAML never holds', () => {
expect(sameDraft({ title: 'A', subtitle: undefined }, { title: 'A' })).toBe(true);
});
});

View File

@@ -113,7 +113,8 @@ export default function AnalyticsView() {
<div className="analytics-bar-chart">
{daysList.map(({ date, pageviews }) => {
const heightPct = Math.round((pageviews / maxDayViews) * 100);
const formattedDate = date.slice(5); // MM-DD
const month = date.slice(5, 8); // "MM-"
const day = date.slice(8); // "DD"
return (
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
<div className="bar-wrapper">
@@ -125,7 +126,11 @@ export default function AnalyticsView() {
<span className="bar-val">{pageviews}</span>
</div>
</div>
<span className="bar-label">{formattedDate}</span>
<span className="bar-label">
{/* Hidden at phone width, where only the day fits (A-18). */}
<span className="bar-label-month">{month}</span>
{day}
</span>
</div>
);
})}

View File

@@ -41,7 +41,11 @@ type AltTextData = AltTextReport & {
/** Which group a doctor check belongs to. An unknown id lands in Content. */
const GROUPS: { id: string; title: string; checks: string[] }[] = [
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn'] },
{
id: 'security',
title: 'Security',
checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn', 'download-metadata'],
},
{
id: 'content',
title: 'Content',
@@ -54,6 +58,7 @@ const GROUPS: { id: string; title: string; checks: string[] }[] = [
'contact',
'privacy',
'settings-values',
'zoom-renditions',
],
},
];
@@ -75,12 +80,17 @@ const FIXES: Record<string, { label: string; href: string }> = {
'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
'content-pages': { label: 'Open pages', href: '/admin/pages' },
'download-metadata': { label: 'Open pages', href: '/admin/pages' },
legal: { label: 'Open settings', href: '/admin/settings/legal' },
contact: { label: 'Open settings', href: '/admin/settings/legal' },
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
// The href is replaced by the finding's own settingsSection.
'settings-values': { label: 'Open settings', href: '/admin/settings/general' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'zoom-renditions': {
label: 'How to set it up',
href: `${DOCS}/docs/gallery-config.md#photo-zoom`,
},
'immich-api': {
label: 'API key permissions',
href: `${DOCS}/README.md#immich-api-key-permissions`,

View File

@@ -54,9 +54,11 @@ import { useLatest } from './useLatest';
import { useVersionedSave } from './useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
import DraftNotice from './DraftNotice';
import { sameDraft } from './sameDraft';
import { reportIfSessionExpired } from './sessionExpiry';
import { IconCamera, IconHome, IconPlus, IconSearch } from './Icons';
import { useNotify } from './Notifications';
import { dndAnnouncements } from './dndAnnouncements';
/** Drop target id of the "Not in menu" group. */
const OFF_MENU_ID = 'offmenu-zone';
@@ -83,7 +85,12 @@ export default function PageBuilder() {
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
/** Set by any edit; `dirty` below also asks whether the edits changed anything. */
const [edited, setDirty] = useState(false);
/** gallery.yaml as last loaded or saved — `serverState`, as state for the comparison. */
const [savedGallery, setSavedGallery] = useState<GalleryState | null>(null);
// Putting an edit back the way it was is not an unsaved change (QA A-19).
const dirty = edited && !(savedGallery && sameDraft(gallery, savedGallery));
const [saveStatus, setSaveStatus] = useState<SaveStatus>(null);
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
/** What the panel shows while no subpage is selected (UX stage 4). */
@@ -205,6 +212,7 @@ export default function PageBuilder() {
versionRef.current = typeof version === 'string' ? version : null;
const parsed = parseGalleryYaml(raw);
serverState.current = parsed;
setSavedGallery(parsed);
const restored = draft.load(JSON.stringify(parsed));
setGallery(restored ?? parsed);
// Not merely "set when restored": on a reload after a backup restore the
@@ -335,6 +343,7 @@ export default function PageBuilder() {
const written = (data.gallery ?? yamlData) as Record<string, unknown>;
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(written)));
serverState.current = asLoaded;
setSavedGallery(asLoaded);
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty and are not replaced by the file as written.
const editedMeanwhile = latestGallery.current !== sent;
@@ -490,6 +499,7 @@ export default function PageBuilder() {
if (change && versionRef.current === change.from) versionRef.current = change.to;
if (serverState.current) {
serverState.current = op(serverState.current);
setSavedGallery(serverState.current);
draft.saved(JSON.stringify(serverState.current));
}
}
@@ -639,6 +649,24 @@ export default function PageBuilder() {
return found?.albumName || id.slice(0, 8) + '...';
}
/** A sortable id as the list shows it, for the drag announcements (QA A-20). */
function dragLabel(id: string | number): string {
const key = String(id);
const subpage = /^subpage-(\d+)$/.exec(key);
if (subpage) return `page "${gallery.subpages[Number(subpage[1])]?.name || 'Untitled'}"`;
const page = /^page-(.+)$/.exec(key);
if (page) {
const found = pages.find((p) => p.slug === page[1]);
return `page "${found?.frontmatter.title || page[1]}"`;
}
const hero = /^hero-(\d+)$/.exec(key);
if (hero) return `homepage hero photo ${Number(hero[1]) + 1}`;
const album = /^album-(.+)-\d+$/.exec(key);
if (album) return `album "${getAlbumName(album[1])}"`;
return 'item';
}
const announcements = dndAnnouncements(dragLabel);
function getAlbumCount(id: string): number {
const found = immichAlbums.find((a) => a.id === id);
return found?.assetCount || 0;
@@ -848,6 +876,7 @@ export default function PageBuilder() {
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
accessibility={{ announcements }}
>
<div className="pb-group">
<div className="pb-group-head">
@@ -1024,6 +1053,7 @@ export default function PageBuilder() {
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleAlbumDragEnd}
accessibility={{ announcements }}
>
<SortableContext
items={filteredAlbums.map((a) => {
@@ -1071,7 +1101,7 @@ export default function PageBuilder() {
<section className="builder-section">
<div className="builder-section-header">
<h2>
<IconHome /> Homepage Hero
<IconHome /> Hero photos
</h2>
<button
className="admin-btn admin-btn-sm"
@@ -1090,6 +1120,7 @@ export default function PageBuilder() {
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleHeroDragEnd}
accessibility={{ announcements }}
>
<SortableContext
items={gallery.hero.map((_, i) => `hero-${i}`)}

View File

@@ -30,6 +30,7 @@ import SeoSection from './settings/SeoSection';
import SecuritySection from './settings/SecuritySection';
import AboutSection, { useAboutEditor } from './settings/AboutSection';
import PageHeader from './PageHeader';
import { sameDraft } from './sameDraft';
const SETTINGS_SECTIONS = [
{ id: 'general', label: 'General' },
@@ -71,6 +72,8 @@ export default function SettingsEditor() {
} | null>(null);
/** Fields an environment variable overrides, path to variable name (#605). */
const [envLocks, setEnvLocks] = useState<EnvLocks>({});
/** SITE_TITLE, the first fallback for an empty title (QA A-15). */
const [siteTitleEnv, setSiteTitleEnv] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
/**
* Set when the settings could not be fetched. It blocks saving, because an
@@ -79,7 +82,8 @@ export default function SettingsEditor() {
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
/** Set by any edit; `dirty` below also asks whether the edits changed anything. */
const [edited, setDirty] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>(null);
const about = useAboutEditor(activeSection === 'about');
@@ -88,6 +92,8 @@ export default function SettingsEditor() {
// builder's and the journal's do (#592) — useUnsavedGuard only covers
// leaving the browser.
const [serverSettings, setServerSettings] = useState<Settings>({});
// Typing a value back to what was saved is not an unsaved change (QA A-19).
const dirty = edited && !sameDraft(settings, serverSettings);
// The draft never sees `sitePassword`: sessionStorage is readable by any
// script on the admin origin, and until it is saved (and hashed) the field
// holds the password as typed. A restored draft takes the password from the
@@ -160,7 +166,13 @@ export default function SettingsEditor() {
: `The server answered ${res.status}.`,
);
}
const { settings: data, siteUrl, envLocks: locks, version } = await res.json();
const {
settings: data,
siteUrl,
envLocks: locks,
version,
siteTitleEnv: titleEnv,
} = await res.json();
const loaded: Settings = data || {};
versionRef.current = typeof version === 'string' ? version : null;
setServerSettings(loaded);
@@ -170,6 +182,7 @@ export default function SettingsEditor() {
setDirty(restored !== null);
setSiteUrlInfo(siteUrl ?? null);
setEnvLocks(locks ?? {});
setSiteTitleEnv(typeof titleEnv === 'string' ? titleEnv : null);
} catch (err) {
console.error('Failed to load settings:', err);
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
@@ -407,7 +420,7 @@ export default function SettingsEditor() {
{/* Content */}
<div className="settings-content">
{activeSection === 'general' && <GeneralSection {...props} />}
{activeSection === 'general' && <GeneralSection {...props} siteTitleEnv={siteTitleEnv} />}
{activeSection === 'theme' && <ThemeSection {...props} />}
{activeSection === 'grid' && <GridSection {...props} />}
{activeSection === 'footer' && <FooterSection {...props} />}

View File

@@ -249,11 +249,11 @@ describe('PageBuilder: edits made while a save is in flight', () => {
</ConfirmProvider>
</NotificationProvider>,
);
fireEvent.click(await screen.findByRole('button', { name: /New subpage/ }));
fireEvent.click(await screen.findByRole('button', { name: 'New subpage' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.click(screen.getByRole('button', { name: /New subpage/ }));
fireEvent.click(screen.getByRole('button', { name: 'New subpage' }));
await act(async () => releasePut!());
await screen.findByText('Saved successfully!');

View File

@@ -146,6 +146,26 @@ describe('journal editor: text blocks as markdown (A-5)', () => {
});
});
describe('journal editor: mode toggles (A-18)', () => {
// Rendered as admin-btn-xs, the active mode carried admin-btn-primary, whose
// background the size class overrode: both modes looked the same.
it('mark the active mode, starting on Visual Blocks and Desktop', async () => {
await renderJournal();
const pressed = (name: string) =>
screen.getByRole('button', { name }).getAttribute('aria-pressed');
expect(pressed('Visual Blocks')).toBe('true');
expect(pressed('Raw Markdown')).toBe('false');
expect(pressed('Desktop')).toBe('true');
expect(pressed('Mobile')).toBe('false');
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
expect(pressed('Visual Blocks')).toBe('false');
expect(pressed('Raw Markdown')).toBe('true');
expect(screen.getByRole('button', { name: 'Raw Markdown' }).className).toContain('active');
});
});
describe('InlineMarkdownField', () => {
function Harness({ initial }: { initial: string }) {
const [html, setHtml] = useState(initial);

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import GeneralSection from '../settings/GeneralSection';
import ZoomOverrideField from '../fields/ZoomOverrideField';
/**
* The admin switches for the lightbox zoom (#467): one site-wide toggle in
* Settings › General, and an inherit / on / off choice per page and album.
* The YAML they produce is covered by the page builder round-trip tests.
*/
afterEach(cleanup);
describe('Settings › General › Photo zoom', () => {
const renderWith = (settings: Record<string, unknown>) => {
const update = vi.fn();
render(<GeneralSection settings={settings} update={update} updateMany={vi.fn()} />);
return { update, row: screen.getByRole('button', { name: /Photo zoom/ }) };
};
it('is shown off when settings.yaml says nothing, and switches it on', () => {
const { update, row } = renderWith({});
expect(row.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(row);
expect(update).toHaveBeenCalledWith('zoom', true);
});
it('switches it off again', () => {
const { update, row } = renderWith({ zoom: true });
expect(row.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(row);
expect(update).toHaveBeenCalledWith('zoom', false);
});
});
describe('ZoomOverrideField', () => {
const renderField = (value: boolean | undefined) => {
const onChange = vi.fn();
render(
<ZoomOverrideField
id="z"
value={value}
onChange={onChange}
inheritLabel="Inherit"
hint="hint"
/>,
);
return { onChange, select: screen.getByLabelText('Photo zoom') as HTMLSelectElement };
};
it('shows the three states', () => {
expect(renderField(undefined).select.value).toBe('');
cleanup();
expect(renderField(true).select.value).toBe('on');
cleanup();
expect(renderField(false).select.value).toBe('off');
});
it('reports true, false, and undefined for inherit', () => {
const { onChange, select } = renderField(undefined);
fireEvent.change(select, { target: { value: 'on' } });
fireEvent.change(select, { target: { value: 'off' } });
fireEvent.change(select, { target: { value: '' } });
expect(onChange.mock.calls).toEqual([[true], [false], [undefined]]);
});
});

View File

@@ -0,0 +1,28 @@
import type { Announcements, UniqueIdentifier } from '@dnd-kit/core';
/**
* Screen reader announcements for a sortable list, in words (QA A-20).
*
* dnd-kit's defaults read out the sortable ids — "Picked up draggable item
* subpage-3. Draggable item page-pricing was moved over droppable area
* subpage-1." Those ids are internal. `label` turns one into what the list
* shows, e.g. `page "Pricing"`.
*/
export function dndAnnouncements(label: (id: UniqueIdentifier) => string): Announcements {
return {
onDragStart: ({ active }) => `Picked up ${label(active.id)}.`,
onDragOver: ({ active, over }) =>
over
? `${capitalise(label(active.id))} is at the position of ${label(over.id)}.`
: `${capitalise(label(active.id))} is no longer over the list.`,
onDragEnd: ({ active, over }) =>
over
? `${capitalise(label(active.id))} was dropped at the position of ${label(over.id)}.`
: `${capitalise(label(active.id))} was dropped.`,
onDragCancel: ({ active }) => `Moving ${label(active.id)} was cancelled.`,
};
}
function capitalise(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}

View File

@@ -0,0 +1,46 @@
'use client';
import type { ReactNode } from 'react';
/**
* Lightbox zoom for one page or album (#467): inherit, on, or off.
*
* Three states rather than a switch, because `zoom: false` is a setting of its
* own — it switches zoom off here while the site (or the page) has it on —
* and a switch can only say true or absent. A select, like the layout
* override beside it, with inheriting as the empty value, so the YAML keeps no
* key until someone picks one.
*/
export default function ZoomOverrideField({
id,
value,
onChange,
inheritLabel,
hint,
}: {
id: string;
value: boolean | undefined;
onChange: (value: boolean | undefined) => void;
inheritLabel: string;
hint: ReactNode;
}) {
const current = value === true ? 'on' : value === false ? 'off' : '';
return (
<div className="admin-field">
<label htmlFor={id}>Photo zoom</label>
<select
id={id}
value={current}
onChange={(e) => {
const next = e.target.value;
onChange(next === 'on' ? true : next === 'off' ? false : undefined);
}}
>
<option value="">{inheritLabel}</option>
<option value="on">On — visitors can zoom to full resolution</option>
<option value="off">Off</option>
</select>
<span className="admin-field-hint">{hint}</span>
</div>
);
}

View File

@@ -116,12 +116,19 @@ export function BlockFields({
{block.type === 'photo' && (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div
{/* A button, not a clickable div, so the keyboard reaches it. */}
<button
type="button"
aria-label={block.assetId ? 'Change photo' : 'Pick a photo'}
style={{
width: '140px',
height: '96px',
flexShrink: 0,
background: 'rgba(0,0,0,0.3)',
border: 'none',
padding: 0,
color: 'inherit',
font: 'inherit',
borderRadius: '6px',
overflow: 'hidden',
display: 'flex',
@@ -140,13 +147,13 @@ export function BlockFields({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetId}`}
alt="Thumb"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick</span>
)}
</div>
</button>
<div
style={{
@@ -213,10 +220,16 @@ export function BlockFields({
}}
>
{[0, 1].map((pIdx) => (
<div
<button
type="button"
key={pIdx}
aria-label={`${block.assetIds[pIdx] ? 'Change' : 'Pick'} photo ${pIdx + 1} of the pair`}
style={{
height: '90px',
width: '100%',
padding: 0,
color: 'inherit',
font: 'inherit',
background: 'rgba(0,0,0,0.3)',
borderRadius: '6px',
overflow: 'hidden',
@@ -241,13 +254,13 @@ export function BlockFields({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetIds[pIdx]}`}
alt="Thumb"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick Photo #{pIdx + 1}</span>
)}
</div>
</button>
))}
</div>
<div>

View File

@@ -401,25 +401,26 @@ export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: Jo
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
{/* The admin's segmented control (QA A-18). As `admin-btn-xs` buttons the
active one never showed: `.admin-btn-xs` sets its own background
after `.admin-btn-primary`, so both modes looked identical. */}
<div
style={{
display: 'flex',
gap: '4px',
background: 'rgba(255,255,255,0.06)',
borderRadius: '6px',
padding: '2px',
}}
className="segmented-control segmented-control--sm"
role="group"
aria-label="Editor mode"
>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'blocks' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${editorMode === 'blocks' ? 'active' : ''}`}
aria-pressed={editorMode === 'blocks'}
onClick={() => setEditorMode('blocks')}
>
Visual Blocks
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'markdown' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${editorMode === 'markdown' ? 'active' : ''}`}
aria-pressed={editorMode === 'markdown'}
onClick={() => setEditorMode('markdown')}
>
Raw Markdown

View File

@@ -92,17 +92,24 @@ export function JournalPreview({ parsed, albumAssets }: JournalPreviewProps) {
<span className="journal-preview-bar-label">
<IconEye size={13} /> Realtime Theme Preview
</span>
<div style={{ display: 'flex', gap: '4px' }}>
{/* Same control and same fix as the editor's mode toggle. */}
<div
className="segmented-control segmented-control--sm"
role="group"
aria-label="Preview width"
>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'desktop' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${viewport === 'desktop' ? 'active' : ''}`}
aria-pressed={viewport === 'desktop'}
onClick={() => setViewport('desktop')}
>
Desktop
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'mobile' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${viewport === 'mobile' ? 'active' : ''}`}
aria-pressed={viewport === 'mobile'}
onClick={() => setViewport('mobile')}
>
Mobile

View File

@@ -110,11 +110,12 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
}
};
const handleDelete = async (slug: string) => {
const handleDelete = async (slug: string, title?: string) => {
// Named by title, as the page delete is; the slug only when there is none.
const ok = await confirm({
title: `Delete “${slug}”?`,
title: `Delete “${title || slug}”?`,
message: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete',
confirmLabel: 'Delete entry',
danger: true,
});
if (!ok) return;
@@ -244,7 +245,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-danger"
onClick={() => handleDelete(entry.slug)}
onClick={() => handleDelete(entry.slug, entry.frontmatter.title)}
title="Delete entry"
style={{ marginLeft: 'auto' }}
>

View File

@@ -1,8 +1,9 @@
/* ── Journal Studio & Editor CSS ──────────────────────────────────── */
/*
* The studio is a full-width workspace: it breaks out of the 1120px
* `.admin-main` column so the split view has room for editor *and* preview.
* The studio is a full-width workspace: it breaks out of the shared
* `--admin-content-width` column so the split view has room for editor *and*
* preview.
*/
.admin-main:has(> .journal-studio),
.admin-main:has(> .journal-editor-container) {

View File

@@ -38,7 +38,7 @@ export function SortableAlbumCard({
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<div ref={setNodeRef} style={style}>
<AlbumCard
album={album}
name={name}
@@ -46,7 +46,7 @@ export function SortableAlbumCard({
thumbnailId={thumbnailId}
onRemove={onRemove}
onEdit={onEdit}
dragListeners={listeners}
dragListeners={{ ...attributes, ...listeners }}
highlighted={highlighted}
/>
</div>
@@ -86,7 +86,12 @@ export default function AlbumCard({
>
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
<div
className="album-tile-drag"
{...dragListeners}
aria-label={`Reorder ${name}`}
title="Drag to reorder"
>
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
)}

View File

@@ -6,6 +6,7 @@ import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
import ZoomOverrideField from '../fields/ZoomOverrideField';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AlbumDrawerProps {
@@ -153,6 +154,14 @@ export default function AlbumDrawer({
</div>
</button>
</div>
<ZoomOverrideField
id="album-zoom"
value={album.zoom}
onChange={(zoom) => onUpdate({ zoom })}
inheritLabel="Inherit from page / site settings"
hint="Lets visitors zoom into a photo in the viewer to check sharpness. JPEG and AVIF originals are shown without their GPS data; HEIC and RAW need Immich's full-size previews. PNG, WebP, GIF and photos edited in Immich are never zoomed. Applies where the album is listed here."
/>
</div>
<div className="admin-sheet-col admin-sheet-col--divided">

View File

@@ -35,8 +35,17 @@ export function SortableHeroTile({
};
return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
<div ref={setNodeRef} style={style} className="hero-tile">
{/* The handle, not the tile, is the sortable control: the tile holds a
remove button, and a button inside role="button" is nested
interactive content (QA A-20). */}
<div
className="hero-tile-drag"
{...attributes}
{...listeners}
aria-label={`Reorder hero photo ${index + 1}`}
title="Drag to reorder"
>
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
@@ -88,9 +97,14 @@ export function SortableSubpageTile({
style={style}
className={`subpage-tile ${isActive ? 'active' : ''}`}
onClick={onClick}
{...attributes}
>
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<div
className="subpage-tile-drag"
{...attributes}
{...listeners}
aria-label={`Reorder ${sp.name || 'page'}`}
title="Drag to reorder"
>
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
@@ -185,9 +199,14 @@ export function SortableSubpageRow({
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${sp.enabled === false ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<span
className="pb-row-grip"
{...attributes}
{...listeners}
aria-label={`Reorder ${sp.name || 'page'}`}
title="Drag to reorder"
>
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
@@ -252,9 +271,14 @@ export function SortablePageRow({
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${draft || missing ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<span
className="pb-row-grip"
{...attributes}
{...listeners}
aria-label={`Reorder ${title}`}
title="Drag to reorder"
>
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>

View File

@@ -23,6 +23,7 @@ import {
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
import SubpagePreview from './SubpagePreview';
import PasswordField from '../fields/PasswordField';
import ZoomOverrideField from '../fields/ZoomOverrideField';
import {
normalizeSubpageGrid,
type ActiveEditAlbumAddress,
@@ -120,20 +121,20 @@ export default function SubpageDrawer({
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
</div>
<div className="subpage-drawer-header-actions">
<div className="segmented-control" style={{ padding: '2px' }}>
<div className="segmented-control segmented-control--sm" role="group" aria-label="View">
<button
type="button"
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
aria-pressed={drawerMode === 'edit'}
onClick={() => onDrawerModeChange('edit')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<IconPencil size={14} /> Edit
</button>
<button
type="button"
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
aria-pressed={drawerMode === 'preview'}
onClick={() => onDrawerModeChange('preview')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<IconSearch size={14} /> Live Preview
</button>
@@ -189,7 +190,9 @@ export default function SubpageDrawer({
onChange={(e) =>
updateSubpage(spIndex, { title: e.target.value || undefined })
}
placeholder="Display title (defaults to name)"
// Short enough for the half-width column at any size; the
// old "Display title (defaults to name)" was cut off (A-18).
placeholder="Same as page name"
/>
</div>
<div className="admin-field">
@@ -343,6 +346,19 @@ export default function SubpageDrawer({
</select>
</div>
{/* Zoom belongs to the album viewer; a photo essay has none. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && !sp.essayFile && (
<div style={{ marginTop: '1rem' }}>
<ZoomOverrideField
id="subpage-zoom"
value={sp.zoom}
onChange={(zoom) => updateSubpage(spIndex, { zoom })}
inheritLabel="Inherit from site settings"
hint="Applies to every album on this page that does not set its own. Settings › General has the site-wide switch."
/>
</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

View File

@@ -30,7 +30,9 @@ export function hasAlbumOptions(entry: AlbumEntry): boolean {
entry.grid ||
entry.coverPosition ||
entry.location ||
entry.download,
entry.download ||
// `zoom: false` is an option too: it switches zoom off for this album.
entry.zoom !== undefined,
);
}
@@ -52,6 +54,7 @@ export function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[
coverPosition: value.coverPosition,
location: value.location,
download: value.download,
zoom: typeof value.zoom === 'boolean' ? value.zoom : undefined,
};
});
}
@@ -82,6 +85,8 @@ export function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
// Same story as location: set by hand in YAML since #475, with no UI, so
// the builder was turning original downloads back off on the next save.
if (entry.download) val.download = entry.download;
// Both values mean something: false switches zoom off for this album.
if (entry.zoom !== undefined) val.zoom = entry.zoom;
return { [entry.id]: val };
});
}

View File

@@ -115,7 +115,7 @@ export const addSubpage = (g: GalleryState): GalleryState => ({
...g,
subpages: [
...g.subpages,
{ name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined },
{ name: `New subpage ${g.subpages.length + 1}`, albums: [], sections: undefined },
],
});

View File

@@ -62,6 +62,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
@@ -91,6 +92,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
zoom: typeof sp.zoom === 'boolean' ? sp.zoom : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
@@ -127,6 +129,8 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
if (sp.hidden === true) entry.hidden = true;
// Both values mean something: false switches proofing off for this page.
if (sp.proofing !== undefined) entry.proofing = sp.proofing;
// Likewise for zoom (#467).
if (sp.zoom !== undefined) entry.zoom = sp.zoom;
if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;

View File

@@ -24,6 +24,11 @@ export interface AlbumEntry {
location?: string;
/** Whether visitors may download the originals from this album (#475). */
download?: boolean;
/**
* Lightbox zoom for this album: true/false override the page and the site,
* undefined inherits (#467).
*/
zoom?: boolean;
}
export interface Section {
@@ -43,6 +48,11 @@ export interface Subpage {
* direction. No control in the builder yet; carried so a save keeps it.
*/
proofing?: boolean;
/**
* Lightbox zoom for the albums on this page, over the site setting in either
* direction; undefined inherits (#467).
*/
zoom?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean;
essayText?: string;

View File

@@ -0,0 +1,31 @@
/**
* Whether an editor's state is still what was last loaded or saved (QA A-19).
*
* The editors set a dirty flag on every edit, so typing a value back to what
* it was left "Unsaved changes" up, the leave guard armed and the draft
* stored. Comparing against the saved state fixes that, but not by plain
* JSON: an edit that is undone does not always undo its shape. The settings
* form creates the parent object of a path it writes (`contact: {}`), and a
* key that is deleted and typed again moves to the end of its object. So the
* comparison ignores key order, `undefined` and empty objects — none of which
* reaches the YAML that is written.
*/
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
const c = canonical((value as Record<string, unknown>)[key]);
if (c === undefined) continue;
if (c && typeof c === 'object' && !Array.isArray(c) && Object.keys(c).length === 0) continue;
out[key] = c;
}
return out;
}
return value;
}
export function sameDraft(a: unknown, b: unknown): boolean {
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
}

View File

@@ -1,9 +1,10 @@
'use client';
import * as Icons from '../Icons';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function FooterSection({ settings, update }: SectionProps) {
export default function FooterSection({ settings, update, fieldErrors }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -67,9 +68,13 @@ export default function FooterSection({ settings, update }: SectionProps) {
{(settings.navLinks || []).map((link, i) => (
<div className="admin-field-row" key={i}>
<div className="admin-field">
<label htmlFor="footer-label">Label</label>
<label htmlFor={`navlink-${i}-label`}>Label</label>
<input
id="footer-label"
id={`navlink-${i}-label`}
aria-invalid={fieldErrors?.[`navLinks.${i}.label`] ? true : undefined}
aria-describedby={
fieldErrors?.[`navLinks.${i}.label`] ? `navlink-${i}-label-error` : undefined
}
value={link.label || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
@@ -78,11 +83,19 @@ export default function FooterSection({ settings, update }: SectionProps) {
}}
placeholder="Shop"
/>
<FieldError
id={`navlink-${i}-label-error`}
message={fieldErrors?.[`navLinks.${i}.label`]}
/>
</div>
<div className="admin-field">
<label htmlFor="footer-url">URL</label>
<label htmlFor={`navlink-${i}-url`}>URL</label>
<input
id="footer-url"
id={`navlink-${i}-url`}
aria-invalid={fieldErrors?.[`navLinks.${i}.url`] ? true : undefined}
aria-describedby={
fieldErrors?.[`navLinks.${i}.url`] ? `navlink-${i}-url-error` : undefined
}
value={link.url || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
@@ -91,6 +104,10 @@ export default function FooterSection({ settings, update }: SectionProps) {
}}
placeholder="https://shop.example.com"
/>
<FieldError
id={`navlink-${i}-url-error`}
message={fieldErrors?.[`navLinks.${i}.url`]}
/>
</div>
<button
type="button"

View File

@@ -4,18 +4,28 @@ import { useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { resolveExifDisplay } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
import { SUPPORTED_LOCALES, getDictionary, resolveLocale } from '@/lib/i18n';
import { FeatureGroup, FeatureRow, SettingRow } from './fields';
import type { SectionProps } from './types';
import { useConfirm } from '../ConfirmDialog';
export default function GeneralSection({ settings, update, updateMany }: SectionProps) {
export default function GeneralSection({
settings,
update,
updateMany,
siteTitleEnv,
}: SectionProps & { siteTitleEnv?: string | null }) {
const confirm = useConfirm();
// What the site shows while the field is empty, resolved as getConfig does:
// SITE_TITLE, else the site language's word for a gallery (QA A-15).
const fallbackTitle = siteTitleEnv || getDictionary(resolveLocale(settings.lang)).common.gallery;
// 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 [faviconUploading, setFaviconUploading] = useState(false);
const [faviconStatus, setFaviconStatus] = useState<SaveStatus>(null);
/** Bumped after an upload or reset so the preview fetches the new icon. */
const [faviconVersion, setFaviconVersion] = useState(0);
// 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
@@ -62,8 +72,14 @@ export default function GeneralSection({ settings, update, updateMany }: Section
id="general-site-title"
value={settings.title || ''}
onChange={(e) => update('title', e.target.value)}
placeholder="My Portfolio"
placeholder={fallbackTitle}
aria-describedby="general-site-title-hint"
/>
<p id="general-site-title-hint" className="admin-field-hint">
{settings.title
? 'Shown in the header, the browser tab and link previews.'
: `Empty, so the site shows “${fallbackTitle}”${siteTitleEnv ? ', from SITE_TITLE' : ''}.`}
</p>
</div>
<div className="admin-field">
<label htmlFor="general-subtitle">Subtitle</label>
@@ -136,6 +152,13 @@ export default function GeneralSection({ settings, update, updateMany }: Section
checked={settings.proofing?.enabled !== false}
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
<FeatureRow
icon={<Icons.IconSearch size={15} />}
title="Photo zoom"
description="Visitors zoom into a photo to full resolution (1:1) to check sharpness. Pages and albums can override this"
checked={settings.zoom === true}
onToggle={() => update('zoom', !settings.zoom)}
/>
</div>
{/* Shown even while proofing is off globally: a subpage can switch it
on for itself (`proofing: true`), so the address still has to be
@@ -249,12 +272,30 @@ export default function GeneralSection({ settings, update, updateMany }: Section
</FeatureGroup>
<div className="admin-field favicon-field">
<span className="admin-field-label">Favicon</span>
<span className="admin-field-label" id="favicon-label">
Favicon
</span>
<div className="favicon-row">
{/* eslint-disable-next-line @next/next/no-img-element -- the site's own icon route, a 32px preview */}
<img
className="favicon-preview"
src={`/api/favicon?v=${faviconVersion}`}
alt="Current favicon"
width={32}
height={32}
/>
{/* The native file input stays the control — focusable, keyboard
operable, announced as a file picker — but is visually hidden;
its label is drawn as the admin's secondary button, with the
focus ring passed through (QA A-18). */}
<input
id="favicon-file"
className="favicon-file-input"
type="file"
accept=".svg,.png,.ico,.jpg,.jpeg"
disabled={faviconUploading}
aria-labelledby="favicon-label favicon-file-button"
aria-describedby="favicon-hint"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -273,6 +314,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
if (res.ok) setFaviconVersion((v) => v + 1);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Upload failed' });
} finally {
@@ -281,9 +323,16 @@ export default function GeneralSection({ settings, update, updateMany }: Section
}
}}
/>
<label
htmlFor="favicon-file"
id="favicon-file-button"
className="admin-btn admin-btn-sm admin-btn-secondary favicon-upload-btn"
>
<Icons.IconImage size={14} /> Upload icon
</label>
<button
type="button"
className="admin-btn"
className="admin-btn admin-btn-sm admin-btn-secondary"
disabled={faviconUploading}
onClick={async () => {
// Deletes the uploaded file at once, outside the staged form.
@@ -304,6 +353,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
if (res.ok) setFaviconVersion((v) => v + 1);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Reset failed' });
} finally {
@@ -318,7 +368,7 @@ export default function GeneralSection({ settings, update, updateMany }: Section
{faviconStatus && (
<p className={`save-message ${faviconStatus.kind}`}>{faviconStatus.message}</p>
)}
<span className="admin-field-hint">
<span id="favicon-hint" className="admin-field-hint">
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the
bundled default.
</span>

View File

@@ -5,9 +5,10 @@ import ToggleCard from '../fields/ToggleCard';
import PrivacyEditor from '../PrivacyEditor';
import EnvLockNote from '../fields/EnvLockNote';
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function LegalSection({ settings, update, envLocks }: SectionProps) {
export default function LegalSection({ settings, update, envLocks, fieldErrors }: SectionProps) {
const notifyLock = envLocks?.['contact.notifyUrl'];
// The Impressum drops a non-http(s) contact URL with a warning in the server
// log, where nobody looks; say it here instead.
@@ -237,6 +238,14 @@ export default function LegalSection({ settings, update, envLocks }: SectionProp
parseInt(e.target.value) || CONTACT_RETENTION_DEFAULT,
)
}
aria-invalid={fieldErrors?.['contact.retentionDays'] ? true : undefined}
aria-describedby={
fieldErrors?.['contact.retentionDays'] ? 'contact-retention-error' : undefined
}
/>
<FieldError
id="contact-retention-error"
message={fieldErrors?.['contact.retentionDays']}
/>
</div>
</div>

View File

@@ -4,10 +4,10 @@ import { useState, type CSSProperties } from 'react';
import * as Icons from '../Icons';
import OptionGrid, { toOptions } from '../fields/OptionGrid';
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { PRESET_IDS, PRESET_REGISTRY } from '@/lib/config/presets';
import { effectiveTheme, FeatureRow, FieldError } from './fields';
import type { SectionProps } from './types';
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
@@ -36,7 +36,7 @@ const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
typographic: { label: 'Typographic', desc: 'Oversized text masthead, no photos' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: {
label: 'Cover (Experimental)',
@@ -79,15 +79,26 @@ function HeroStylePreview({ value }: { value: string }) {
<div className="hero-demo-photo banner" />
</>
)}
{/* Text only, like the hero it stands for: the typographic hero
renders no photo, so the card must not promise any. */}
{value === 'typographic' && (
<>
<div className="demo-line title giant" />
<div className="hero-demo-grid2">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
<div className="demo-line sub" />
<div className="hero-demo-divider" />
<div className="hero-demo-nav">
<div className="demo-line" />
<div className="demo-line" />
<div className="demo-line" />
</div>
</>
)}
{value === 'cover' && (
<div className="hero-demo-full hero-demo-cover-splash">
<div className="demo-line title light" />
<div className="hero-demo-enter" />
</div>
)}
{value === 'mosaic' && (
<div className="hero-demo-mosaic">
<div className="hero-demo-photo big" />
@@ -103,9 +114,10 @@ function HeroStylePreview({ value }: { value: string }) {
}
/**
* Card metadata for the theme picker. `font`, `radius` and `frame` mirror the
* real preset definitions in lib/config/theme.ts so the mini mockups show what
* the preset actually does; `gap` is the visual density of its gallery grid.
* Card metadata for the theme picker, from the preset registry
* (lib/config/presets.ts). `font`, `radius` and `frame` are the preset's own
* values so the mini mockups show what the preset actually does; `gap` is the
* visual density of its gallery grid.
*/
const THEME_INFO: Record<
string,
@@ -121,92 +133,19 @@ const THEME_INFO: Record<
frame: 'none' | 'passepartout' | 'shadow';
gap: number;
}
> = {
studio: {
label: 'Studio',
desc: 'Clean, high-contrast grid with sans-serif type.',
bg: '#141414',
tile: '#242424',
accent: '#e60012',
font: 'Playfair Display',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 4,
},
'studio-modern': {
label: 'Studio Modern',
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
bg: '#121212',
tile: '#191919',
accent: '#e60012',
font: 'Archivo',
type: 'sans',
radius: 0,
frame: 'none',
gap: 3,
},
minimal: {
label: 'Minimal',
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
bg: '#ffffff',
tile: '#f3f3f3',
accent: '#111111',
font: 'Geist',
type: 'sans',
radius: 0,
frame: 'none',
gap: 2,
},
editorial: {
label: 'Editorial',
desc: 'Warm backgrounds, elegant serifs and large headers.',
bg: '#fbf9f4',
tile: '#e5dfd4',
accent: '#b89053',
font: 'Bodoni Moda',
type: 'serif',
radius: 0,
frame: 'shadow',
gap: 8,
},
classic: {
label: 'Classic',
desc: 'Soft traditional photographer portfolio with shadows.',
bg: '#f7f7f7',
tile: '#ffffff',
accent: '#444444',
font: 'Cinzel',
type: 'serif',
radius: 12,
frame: 'passepartout',
gap: 7,
},
noir: {
label: 'Noir',
desc: 'High drama absolute pitch black, stark high-fashion look.',
bg: '#000000',
tile: '#151515',
accent: '#ffffff',
font: 'Libre Baskerville',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 6,
},
monograph: {
label: 'Monograph',
desc: 'Typewriter monospace font, grid borders and document feel.',
bg: '#f4f4f6',
tile: '#ffffff',
accent: '#555555',
font: 'Instrument Serif',
type: 'mono',
radius: 0,
frame: 'none',
gap: 5,
},
};
> = Object.fromEntries(
PRESET_REGISTRY.map((p) => [
p.id,
{
label: p.label,
desc: p.description,
...p.card,
font: p.theme.fonts.heading,
radius: p.theme.radius,
frame: p.theme.photoFrame,
},
]),
);
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
at module evaluation and reads it through themeInfo(); declared above, it
@@ -232,7 +171,7 @@ function themeInfo(value: string) {
);
}
const PRESET_OPTIONS = PRESETS.map((value) => ({
const PRESET_OPTIONS = PRESET_IDS.map((value) => ({
value,
label: themeInfo(value).label,
desc: themeInfo(value).desc,

View File

@@ -25,6 +25,8 @@ export interface Settings {
transitions?: boolean;
scrollToTop?: boolean;
analytics?: boolean;
/** Lightbox zoom to full resolution (#467); off unless true. */
zoom?: boolean;
theme?: {
preset?: string;
accent?: string;

View File

@@ -7,7 +7,6 @@
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1100px;
}
.analytics-header {
@@ -54,11 +53,15 @@
color: var(--admin-text-muted);
}
/* Set like the Overview's stat numbers. `--font-mono` was never defined, so
these fell through to the browser's bare `monospace`. Tabular figures keep
the digits aligned without it. */
.metric-value {
font-size: 1.8rem;
font-weight: 700;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--admin-text);
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
}
.metric-sub {
@@ -138,7 +141,8 @@
font-size: 0.68rem;
color: var(--admin-text-muted);
margin-top: 0.5rem;
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.analytics-top-pages {
@@ -154,14 +158,17 @@
}
.page-rank {
flex-shrink: 0;
font-size: 0.8rem;
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
color: var(--admin-text-muted);
width: 24px;
}
/* min-width: 0 lets a long path wrap instead of pushing the page sideways. */
.page-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
@@ -169,17 +176,23 @@
.page-path-line {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.85rem;
}
/* Paths are the one thing here set in monospace, as slugs are elsewhere. */
.page-path {
font-family: var(--font-mono, monospace);
min-width: 0;
overflow-wrap: anywhere;
font-family: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
color: var(--admin-text);
}
.page-count {
flex-shrink: 0;
white-space: nowrap;
font-size: 0.75rem;
color: var(--admin-text-muted);
}
@@ -202,6 +215,32 @@
transition: width 0.3s ease;
}
/* Phone width (QA A-18). The page was 513px wide at 390: a long path could
not wrap (fixed above), and fourteen "09-19" labels broke over two lines
under bars squeezed by 12px gaps. Panel padding and gaps tighten, and the
label drops the month, which every bar's title still carries. */
@media (max-width: 640px) {
.analytics-panel {
padding: 1.25rem 1rem;
}
.analytics-bar-chart {
gap: 0.2rem;
}
.bar-col {
min-width: 0;
}
.bar-label-month {
display: none;
}
.top-page-row {
gap: 0.6rem;
}
}
.analytics-empty {
margin: 0;
padding: 2rem 1rem;

View File

@@ -85,6 +85,13 @@
color: #fca5a5;
}
/* Small buttons carry short action labels ("+ Add Album", "Pick several");
broken over two lines in a tight row they read as two buttons. */
.admin-btn-sm,
.admin-btn-xs {
white-space: nowrap;
}
.admin-btn-sm {
padding: 0 0.8rem;
min-height: 30px;

View File

@@ -3,9 +3,9 @@
/* Each check is one line with a severity dot: the point is scanning a list for
the ones that are not green, not reading prose. Green ones recede. */
/* Fills the shared content column (--admin-content-width); the check details
keep their own 70ch measure below. */
.diag-page {
max-width: 880px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;

View File

@@ -9,11 +9,62 @@
}
.favicon-row {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.favicon-row input[type='file'] {
font-size: 0.8rem;
/* The current icon, on the input surface so a dark or a light icon both show. */
.favicon-preview {
width: 32px;
height: 32px;
padding: 4px;
box-sizing: content-box;
object-fit: contain;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
}
/* The native input stays in the tab order and the accessibility tree but out
of sight: its "Choose File / No file chosen" box was the one raw browser
control left in the admin (QA A-18). The label below is what is seen.
Doubled class to outrank `.admin-field input`, which sizes every input. */
.admin-field .favicon-file-input.favicon-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A <label>, so `.admin-field label` (block, uppercase caption) reaches it
too; this puts the button back. */
.admin-field .favicon-upload-btn {
display: inline-flex;
margin: 0;
font-size: 0.8rem;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
cursor: pointer;
}
/* Focus lands on the hidden input; the ring is drawn on its label. */
.favicon-file-input:focus-visible + .favicon-upload-btn {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.favicon-file-input:disabled + .favicon-upload-btn {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}

View File

@@ -4,7 +4,6 @@
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 900px;
}
.messages-count {
@@ -113,6 +112,7 @@
}
.messages-text {
max-width: 70ch;
margin: 0 0 1rem;
white-space: pre-wrap;
line-height: 1.55;

View File

@@ -27,6 +27,14 @@
--admin-sidebar-bg: #090909;
--admin-radius: 4px;
--admin-sidebar-width: 232px;
/* The one outer content column every workspace screen shares. Pages,
Overview and Diagnostics used to end at three different right edges
(QA A-18). Up to a 1592px window every screen simply fills the
workspace; wider, they all stop here instead of each at its own cap.
Text inside keeps its own narrower measure (the 70ch details). Only the
split editors (journal, content pages) break out of it. */
--admin-content-width: 1280px;
--admin-workspace-pad-x: 2.5rem;
font-family: var(--admin-font);
}
@@ -276,8 +284,8 @@
}
.admin-workspace {
max-width: 1180px;
padding: 2.25rem 2.5rem 6rem;
max-width: calc(var(--admin-content-width) + 2 * var(--admin-workspace-pad-x));
padding: 2.25rem var(--admin-workspace-pad-x) 6rem;
margin: 0;
}
@@ -338,21 +346,26 @@
/* Section and field labels in the site's caption voice. */
.admin-layout .admin-field > label,
.admin-layout .settings-group-title,
.admin-layout .analytics-section-title h3 {
.admin-layout .settings-group-title {
font-family: var(--admin-font-label);
letter-spacing: 0.12em;
}
/* Panel headings are headings, not captions: the caption font made the
analytics titles a mixed-case line of monospace (QA A-18). They read like
the settings section headings; monospace stays with the paths. */
.admin-layout .settings-section-header h3,
.admin-layout .analytics-header h2 {
.admin-layout .analytics-header h2,
.admin-layout .analytics-section-title h3 {
font-family: var(--admin-font);
font-weight: 500;
letter-spacing: -0.01em;
}
/* Section icons stay neutral; the accent is not decoration. */
.admin-layout .settings-section-header h3 svg,
.admin-layout .analytics-header h2 svg {
.admin-layout .analytics-header h2 svg,
.admin-layout .analytics-section-title h3 svg {
color: var(--admin-text-muted);
}
@@ -463,6 +476,7 @@
.overview-attention-detail {
display: block;
max-width: 70ch;
margin-top: 0.15rem;
color: var(--admin-text-muted);
font-size: 0.8rem;
@@ -625,8 +639,8 @@
/* The journal list is an ordinary screen and gets the workspace column; only
the editor (split view) needs the full width journal-studio.css gives it. */
.admin-app .admin-main:has(> .journal-studio) {
max-width: 1180px;
padding: 2.25rem 2.5rem 6rem;
max-width: calc(var(--admin-content-width) + 2 * var(--admin-workspace-pad-x));
padding: 2.25rem var(--admin-workspace-pad-x) 6rem;
}
@media (max-width: 860px) {
@@ -701,12 +715,6 @@
/* ── Page builder: structure list + panel (stage 4) ────────── */
/* The builder uses the full workspace width: the list and the panel both
need room, and the panel carries the live preview. */
.admin-app .admin-main:has(.pb-split) {
max-width: none;
}
.pb-split {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);

View File

@@ -52,6 +52,9 @@
.settings-content {
min-width: 0;
/* Room below the last input for the floating save bar (fixed, ~4rem tall
at 1.5rem from the bottom), so it never sits on top of a field. */
padding-bottom: 6rem;
}
.settings-panel h3 {

View File

@@ -81,7 +81,7 @@
flex: 1;
/* Extra bottom padding so the floating save bar never covers page content. */
padding: 1.5rem 1.5rem 6rem;
max-width: 1120px;
max-width: calc(var(--admin-content-width) + 3rem);
margin: 0 auto;
width: 100%;
}

View File

@@ -142,12 +142,46 @@
.hero-demo-typographic {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.hero-demo-grid2 {
.hero-demo-typographic .demo-line.title.giant {
height: 10px;
margin-bottom: 0;
}
.hero-demo-divider {
width: 12%;
height: 1px;
margin: 0.1rem 0;
background: var(--admin-text-muted);
}
.hero-demo-nav {
display: flex;
gap: 0.25rem;
flex: 1;
flex-direction: column;
align-items: center;
gap: 0.2rem;
width: 100%;
}
.hero-demo-nav .demo-line {
width: 30%;
height: 2px;
}
.hero-demo-cover-splash {
flex-direction: column;
gap: 0.3rem;
}
.hero-demo-enter {
width: 22%;
height: 6px;
border: 1px solid #ffffff;
border-radius: 2px;
}
.hero-demo-mosaic {
@@ -322,6 +356,31 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.segment-btn:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
}
/* Compact size for toolbars: the editor's Visual Blocks / Raw Markdown, its
preview's Desktop / Mobile and the subpage drawer's Edit / Live Preview.
Replaces the inline padding the drawer used to carry. */
.segmented-control--sm {
padding: 2px;
gap: 2px;
border-radius: 8px;
}
.segmented-control--sm .segment-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
min-height: 24px;
font-size: 0.75rem;
border-radius: 6px;
white-space: nowrap;
}
/* Accent Color Swatches */
.accent-picker-wrapper {
display: flex;

View File

@@ -38,9 +38,30 @@ const config = {
albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
contact: { enabled: true, retentionDays: 90 },
legal: { enabled: true },
privacy: { enabled: true },
siteUrl: null,
// Past the gate, /api/analytics/track would write content/analytics.json in
// the checkout; turned off, it answers without writing. The gate runs first,
// so the locked-site rows still exercise it.
analytics: false,
};
/*
* The contact route stores what it accepts under content/messages/ and pushes
* to contact.notifyUrl. Both are replaced here, so an accepted submission
* touches neither the checkout's content/ directory nor the network;
* validateContact stays real, honeypot and fill-time check included.
*/
const contactStore = vi.hoisted(() => ({
saveMessage: vi.fn(async (fields: Record<string, string>) => ({ id: 'x', ...fields })),
notifyNewMessage: vi.fn(async () => {}),
}));
vi.mock('@/lib/contact', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/contact')>()),
...contactStore,
}));
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
@@ -99,6 +120,15 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
),
},
{
// The lightbox zoom's full-resolution file (#467).
name: 'GET /api/zoom/[album]/[id]',
call: async () =>
(await import('../zoom/[album]/[id]/route')).GET(
request('/api/zoom/tok/tok') as never,
{ params: Promise.resolve({ album: 'tok', id: 'tok' }) } as never,
),
},
// Client proofing links: the site password applies to clients as well.
{
name: 'PUT /api/proof/[token]/selection',
@@ -128,11 +158,15 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
),
},
{
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
name: 'POST /api/contact',
// Writes visitor-chosen paths into content/analytics.json. It used to be
// OPEN "to count the gate", but the layout mounts no tracker on the gate.
name: 'POST /api/analytics/track',
call: async () =>
(await import('../contact/route')).POST(
new NextRequest('http://localhost/api/contact', { method: 'POST', body: '{}' }) as never,
(await import('../analytics/track/route')).POST(
new NextRequest('http://localhost/api/analytics/track', {
method: 'POST',
body: '{"path":"/"}',
}) as never,
),
},
];
@@ -152,7 +186,8 @@ const OPEN: Record<string, string> = {
install: 'the first-run wizard; setup-token gated, refuses once installed',
'install/albums': 'the first-run wizard; setup-token gated, refuses once installed',
webhook: 'server-to-server from Immich, HMAC-verified',
'analytics/track': 'counts a view of the gate itself; returns no content',
contact:
'the Impressum links the form as its second contact channel, and /contact is served to a locked site',
};
/** `GET /api/download/[album]/[id]` → `download/[album]/[id]`. */
@@ -253,6 +288,58 @@ describe('routes that must stay open', () => {
// The password gate is set in the theme's fonts; locking them would render
// the gate itself in fallback fonts. Three public font names give nothing away.
describe('POST /api/contact on a locked site', () => {
/** Own rate-limit bucket per case: the route allows three a minute per IP. */
const submit = (ip: string, fields: Record<string, unknown>) =>
import('../contact/route').then(({ POST }) =>
POST(
new NextRequest('http://localhost/api/contact', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-real-ip': ip },
body: JSON.stringify(fields),
}) as never,
),
);
const valid = () => ({
name: 'Erika',
email: 'erika@example.com',
message: 'Wer betreibt diese Seite?',
website: '',
startedAt: Date.now() - 10_000,
});
beforeEach(() => {
config.sitePassword = 'letmein';
contactStore.saveMessage.mockClear();
});
// The Impressum links the form as its second contact channel, and the
// proxy serves /contact to a locked site; the endpoint has to take the post.
it('accepts a valid message without a session', async () => {
const res = await submit('10.0.0.1', valid());
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(contactStore.saveMessage).toHaveBeenCalledTimes(1);
});
it('still drops a filled honeypot and a form sent too fast, unsaved', async () => {
expect((await submit('10.0.0.2', { ...valid(), website: 'spam.example' })).status).toBe(200);
expect((await submit('10.0.0.2', { ...valid(), startedAt: Date.now() })).status).toBe(200);
expect(contactStore.saveMessage).not.toHaveBeenCalled();
});
it('still refuses an invalid message', async () => {
const res = await submit('10.0.0.3', { ...valid(), email: 'not-an-address' });
expect(res.status).toBe(400);
expect(contactStore.saveMessage).not.toHaveBeenCalled();
});
it('still rate-limits at three a minute', async () => {
for (let i = 0; i < 3; i++) expect((await submit('10.0.0.4', valid())).status).toBe(200);
expect((await submit('10.0.0.4', valid())).status).toBe(429);
});
});
it('GET /api/fonts/css answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (
@@ -271,3 +358,49 @@ describe('routes that must stay open', () => {
expect(res.status).not.toBe(401);
});
});
/*
* The pages, not the route handlers: proxy.ts gates those. Driven through the
* real lib/auth here, with the password set, rather than through a mocked
* isSiteUnlocked() as in lib/__tests__/proxy.test.ts.
*/
describe('legal and contact pages on a locked site', () => {
const page = async (path: string, cookie?: string) =>
(await import('@/proxy')).proxy(
new NextRequest(`http://localhost${path}`, cookie ? { headers: { cookie } } : {}),
);
const rewrittenTo = (res: Response) => res.headers.get('x-middleware-rewrite');
beforeEach(() => {
config.sitePassword = 'letmein';
config.legal.enabled = true;
config.privacy.enabled = true;
config.contact.enabled = true;
});
// The gate page is public and links both. A German Impressum has to be
// reachable directly, and neither page carries an album name or asset token.
it.each(['/impressum', '/privacy', '/contact'])(
'%s is served without the password',
async (path) => {
expect(rewrittenTo(await page(path))).toBeNull();
},
);
it('every other page is still rewritten to the gate', async () => {
for (const path of ['/', '/japan', '/journal', '/about', '/contact/x', '/impressum/x']) {
expect(rewrittenTo(await page(path)), path).toContain('/gate');
}
});
it('a switched-off legal or contact page is still a 404, not the gate', async () => {
config.legal.enabled = false;
config.privacy.enabled = false;
config.contact.enabled = false;
for (const path of ['/impressum', '/privacy', '/contact']) {
const res = await page(path);
expect(rewrittenTo(res), path).toBeNull();
expect(res.status, path).toBe(404);
}
});
});

View File

@@ -17,9 +17,9 @@ const fsMock = vi.hoisted(() => ({
copyFile: vi.fn(async () => undefined),
readdir: vi.fn(async () => []),
unlink: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
writeFile: vi.fn(async (..._args: unknown[]) => undefined),
rename: vi.fn(async () => undefined),
readFile: vi.fn(async () => {
readFile: vi.fn(async (..._args: unknown[]): Promise<Buffer> => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}),
}));
@@ -43,13 +43,14 @@ const put = (body: unknown) =>
* brand-new file and the save went ahead with no snapshot (#630).
*/
describe('PUT /api/admin/about backup handling', () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
fs.readFile.mockImplementation(async () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
});
it('saves a brand-new file with no backup', async () => {
vi.mocked(fs.access).mockRejectedValueOnce(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
);
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
expect(res.status).toBe(200);
@@ -58,6 +59,7 @@ describe('PUT /api/admin/about backup handling', () => {
});
it('aborts the save when the file exists but the backup copy fails', async () => {
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('---\nname: Old\n---\n'));
vi.mocked(fs.copyFile).mockRejectedValueOnce(
Object.assign(new Error('EACCES'), { code: 'EACCES' }),
);
@@ -65,4 +67,15 @@ describe('PUT /api/admin/about backup handling', () => {
await expect(put({ meta: { name: 'Me' }, body: 'Hello' })).rejects.toThrow('EACCES');
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('takes no backup for a save that changes nothing (QA A-19)', async () => {
await put({ meta: { name: 'Me' }, body: 'Hello' });
const written = vi.mocked(fs.writeFile).mock.calls[0][1] as string;
vi.mocked(fs.writeFile).mockClear();
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from(written));
const res = await put({ meta: { name: 'Me' }, body: 'Hello' });
expect(res.status).toBe(200);
expect(fs.copyFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* The doctor's zoom check (#467) samples one photo that zoom serves through
* Immich's full-size rendition. A rendition in a format the zoom route refuses
* (WebP) is not "fine" — the doctor used to say OK because one existed.
*/
vi.mock('@/lib/admin/auth', () => ({
isAdminEnabled: () => true,
isAdminAuthenticated: async () => true,
COOKIE_NAME: 'folio_admin_session',
}));
const ALBUM = '11111111-1111-1111-1111-111111111111';
const config = {
needsCredentials: false,
trustedProxyHops: 0,
authSecret: 'x'.repeat(64),
albums: [ALBUM],
standaloneAlbums: [ALBUM],
standaloneAlbumZoom: {},
zoom: true,
albumOverrides: {},
albumDownloads: {},
subpages: [],
albumPasswords: {},
sitePassword: '',
immich: { apiUrl: 'https://immich.example/api', apiKey: 'key' },
immichTimeoutMs: 1000,
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
contact: { enabled: false, retentionDays: 90 },
privacy: { enabled: true },
};
vi.mock('@/lib/config', () => ({
getConfig: () => config,
slugify: (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
}));
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
vi.mock('@/lib/admin/yaml-service', () => ({ readSettingsYaml: async () => null }));
const immichMock = vi.hoisted(() => ({ getAlbum: vi.fn(), streamFullsize: vi.fn() }));
vi.mock('@/lib/immich', () => ({ immich: immichMock }));
import { GET } from '../doctor/route';
import { NextRequest } from 'next/server';
const zoomFinding = async () => {
const body = await (await GET(new NextRequest('http://localhost/api/admin/doctor'))).json();
return body.findings.find((f: { id: string }) => f.id === 'zoom-renditions');
};
const rendition = (contentType: string) => ({
stream: new ReadableStream(),
contentType,
contentLength: '10',
});
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url.includes('/albums')
? ({ ok: true, json: async () => [{ id: ALBUM, albumName: 'Trip' }] } as Response)
: ({ ok: true, json: async () => ({}) } as Response),
),
);
immichMock.getAlbum.mockResolvedValue({
id: ALBUM,
albumName: 'Trip',
assets: [{ id: 'heic', type: 'IMAGE', originalMimeType: 'image/heic' }],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe('GET /api/admin/doctor — zoom renditions', () => {
it('is OK for a JPEG rendition', async () => {
immichMock.streamFullsize.mockResolvedValue(rendition('image/jpeg'));
expect((await zoomFinding())?.level).toBe('ok');
expect(immichMock.streamFullsize).toHaveBeenCalledWith('heic');
});
it('warns when Immich has none', async () => {
immichMock.streamFullsize.mockResolvedValue(null);
const finding = await zoomFinding();
expect(finding?.level).toBe('warn');
expect(finding?.title).toMatch(/no full-size preview/);
});
it('warns, asking for JPEG, when the rendition is WebP (review of #830)', async () => {
immichMock.streamFullsize.mockResolvedValue(rendition('image/webp'));
const finding = await zoomFinding();
expect(finding?.level).toBe('warn');
expect(finding?.title).toMatch(/not JPEG/);
});
});

View File

@@ -6,6 +6,7 @@ import path from 'path';
import yaml from 'js-yaml';
import { atomicWrite } from '@/lib/atomicWrite';
import {
needsBackup,
VersionConflictError,
assertVersion,
baseVersionFrom,
@@ -90,15 +91,8 @@ export const PUT = withAdmin(async (request: Request) => {
// brand-new file: the save went ahead with no snapshot taken (#630). Only
// ENOENT means there is nothing to back up; anything else aborts the save
// before it overwrites the live file.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException & { code?: string }).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

View File

@@ -3,6 +3,9 @@ import { withAdmin } from '@/lib/admin/withAdmin';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getConfig, slugify } from '@/lib/config';
// From the pure schema module, like lib/auth.ts: tests stub the config barrel.
import { resolveZoom } from '@/lib/config/schema';
import { ZOOM_CONTENT_TYPES, zoomSourceFor } from '@/lib/zoomSource';
import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { readSettingsYaml } from '@/lib/admin/yaml-service';
@@ -11,6 +14,8 @@ import { readPrivacy } from '@/lib/privacy';
import { listPageSlugsSync } from '@/lib/admin/pages-service';
import { takenPageSlugs } from '@/lib/admin/pageSlugs';
import { describeCollision, menuPageSlugs, pageSlugCollision } from '@/lib/pages';
import { immich } from '@/lib/immich';
import { isLocationScrubbable } from '@/lib/locationScrub';
import {
checkAlbumIds,
checkAlbumSlugCollisions,
@@ -20,21 +25,26 @@ import {
checkImmichCalls,
checkContact,
checkContentPages,
checkDownloadMetadata,
checkLegal,
checkPrivacy,
checkSettingValues,
checkPasswords,
checkProxyHops,
checkWritable,
checkZoomRenditions,
countForwardedHops,
PROXY_MARKER_HEADERS,
worstLevel,
type AlbumRef,
type AlbumSlugGroup,
type DoctorFinding,
type DownloadAlbumRef,
type ContactRef,
type LegalRef,
type PasswordRef,
type ZoomAlbumRef,
type ZoomRenditionSample,
} from '@/lib/admin/doctor';
/**
@@ -130,6 +140,81 @@ export const GET = withAdmin(async (request: NextRequest) => {
})),
];
findings.push(checkAlbumSlugCollisions(slugGroups, config.albumOverrides, albums, slugify));
// Originals whose metadata a download cannot clean. Through the album
// cache, like the alt-text report; an album that cannot be read is
// already reported above and is skipped here.
const downloadAlbums: DownloadAlbumRef[] = [];
for (const id of config.albums.filter((albumId) => config.albumDownloads?.[albumId])) {
try {
const album = await immich.getAlbum(id);
if (!album) continue;
const uncleanable = album.assets.filter((asset) => !isLocationScrubbable(asset));
downloadAlbums.push({
id,
albumName: album.albumName,
uncleanable: uncleanable.length,
uncleanableWithLocation: uncleanable.filter(
(asset) => asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null,
).length,
});
} catch {
// Immich unreachable: the connection check says so.
}
}
const downloads = checkDownloadMetadata(downloadAlbums);
if (downloads) findings.push(downloads);
// Lightbox zoom (#467): photos a browser cannot show need Immich's
// full-size rendition. Whether Immich has them is only known by asking,
// so one such photo is sampled. "On" here means on for any route to the
// album, standalone or through an enabled subpage.
const zoomAlbums: ZoomAlbumRef[] = [];
let renditionSample: ZoomRenditionSample | null = null;
try {
const zoomOn = (albumId: string) =>
(config.standaloneAlbums.includes(albumId) && resolveZoom(config, albumId)) ||
config.subpages.some(
(sp) =>
sp.enabled !== false &&
sp.albumIds.includes(albumId) &&
resolveZoom(config, albumId, sp),
);
let sample: string | undefined;
for (const id of config.albums.filter(zoomOn)) {
try {
const album = await immich.getAlbum(id);
if (!album) continue;
const viaRendition = album.assets.filter(
(asset) => zoomSourceFor(asset) === 'fullsize',
);
sample ??= viaRendition[0]?.id;
zoomAlbums.push({ id, albumName: album.albumName, needRendition: viaRendition.length });
} catch {
// Immich unreachable: the connection check says so.
}
}
if (sample) {
try {
const rendition = await immich.streamFullsize(sample);
if (!rendition) {
renditionSample = 'missing';
} else {
await (rendition.stream as ReadableStream).cancel().catch(() => {});
// The zoom route serves only what it can scrub: a WebP rendition
// exists, and is still refused.
const type = rendition.contentType.toLowerCase().split(';')[0].trim();
renditionSample = ZOOM_CONTENT_TYPES.has(type) ? 'ok' : { contentType: type };
}
} catch {
// Unknown; reported as nothing rather than as a false alarm.
}
}
} catch {
// Best-effort, like every check here: no finding rather than no report.
}
const zoomFinding = checkZoomRenditions(zoomAlbums, renditionSample);
if (zoomFinding) findings.push(zoomFinding);
}
}

View File

@@ -5,6 +5,7 @@ import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { atomicWrite } from '@/lib/atomicWrite';
import {
needsBackup,
VersionConflictError,
assertVersion,
baseVersionFrom,
@@ -17,6 +18,8 @@ import {
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { PRIVACY_FILENAME, processingFacts, readPrivacy, starterHeadings } from '@/lib/privacy';
import { listSessions } from '@/lib/proofSessions';
import { webhookUrl } from '@/lib/proofWebhook';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const MAX_BACKUPS = 10;
@@ -35,6 +38,12 @@ export const GET = withAdmin(async () => {
!!config.sitePassword ||
config.subpages.some((sp) => !!sp.password) ||
Object.values(config.albumPasswords).some(Boolean),
// An unreadable proofing.json may still hold client data: report it.
hasProofingLinks: await listSessions().then(
(sessions) => sessions.length > 0,
() => true,
),
proofingWebhookUrl: webhookUrl(),
});
// Read before the text: a save landing between the two then makes the
// version stale, which costs a spurious conflict, never a missed one.
@@ -70,15 +79,8 @@ export const PUT = withAdmin(async (request: Request) => {
// Same rule as the about route (#630): only a missing file means there is
// nothing to back up. Any other failure aborts before the live file changes.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
// A save that changes nothing takes no backup either (needsBackup).
if (await needsBackup(filePath, content)) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

View File

@@ -40,6 +40,9 @@ export const GET = withAdmin(async () => {
// Fields an environment variable overrides, as path → variable name. Only
// the names: the values are secrets (a password, an ntfy topic) (#605).
envLocks: resolveEnvLocks(env),
// What an empty title falls back to before the language default, so the
// General section can show the title the site really uses (QA A-15).
siteTitleEnv: env.SITE_TITLE || null,
},
{ headers: { ETag: etag(version) } },
);

View File

@@ -26,7 +26,10 @@ export const GET = withAdmin(
try {
const res = await fetch(
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail`,
// `edited=true`: the pickers show a photo as edited in Immich, like
// the site does (#831). For a photo that is not edited Immich returns
// the same bytes, so it needs no lookup first.
`${config.immich.apiUrl}/assets/${assetId}/thumbnail?size=thumbnail&edited=true`,
{
headers: {
'x-api-key': config.immich.apiKey,

View File

@@ -10,6 +10,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/config', () => ({
getConfigOrNull: vi.fn(() => ({ analytics: true })),
// The site gate reads it; no sitePassword, so the site is open.
getConfig: () => ({ analytics: true }),
}));
const checkRateLimit = vi.fn(

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getConfigOrNull } from '@/lib/config';
import { siteLockResponse } from '@/lib/auth';
import { atomicWrite } from '@/lib/atomicWrite';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -79,6 +80,13 @@ async function saveAnalytics(data: AnalyticsData) {
let writeQueue: Promise<void> = Promise.resolve();
export async function POST(req: NextRequest) {
// A locked site accepts nothing from strangers, the same rule as
// /api/contact: without it anyone could write paths of their choosing into
// analytics.json. The gate page itself is not counted — the layout mounts
// no tracker there — so a visitor who unlocked the site loses nothing.
const locked = siteLockResponse(req);
if (locked) return locked;
try {
const config = getConfigOrNull();
if (config?.analytics === false) {

View File

@@ -8,7 +8,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { siteLockResponse } from '@/lib/auth';
import { notifyNewMessage, saveMessage, validateContact } from '@/lib/contact';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -24,9 +23,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
// The page sits behind the site password; so does the endpoint it posts to.
const locked = siteLockResponse(request);
if (locked) return locked;
// No site-lock check, on purpose: proxy.ts serves /contact to a locked site,
// because the Impressum links the form as its second contact channel. The
// honeypot, the fill-time check, the rate limit and the inbox cap below are
// what stand between a stranger and content/messages/, locked or not.
const ip = getClientIp(request);
const { success, resetAt } = checkRateLimit(`contact:${ip}`, CONTACT_RPM);

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import {
LAT,
cameraHeic,
cameraJpeg,
chunked,
contains,
exifOf,
heicExif,
latin1,
parseTiff,
asciiOf,
} from '@/lib/__tests__/fixtures/location';
/**
* The single-original download serves the file without its location: GPS is
* removed from JPEG/HEIC/AVIF on the way through, camera data stays, and the
* length (and so Content-Length) does not change.
*/
vi.mock('@/lib/config', () => ({ getConfig: vi.fn() }));
vi.mock('@/lib/tokens', () => ({ decodeAssetId: vi.fn((t: string) => t.replace('tok-', '')) }));
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: vi.fn(), streamAsset: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
vi.mock('@/lib/auth', () => ({
siteLockResponse: vi.fn(() => null),
isAlbumReachable: vi.fn(() => true),
}));
vi.mock('next/headers', () => ({
cookies: vi.fn(() => Promise.resolve({ get: () => undefined })),
}));
import { GET } from '../route';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
const mockStream = immich.streamAsset as unknown as ReturnType<typeof vi.fn>;
const ALBUM = {
id: 'album',
albumName: 'Album',
assets: [{ id: 'asset', type: 'IMAGE', originalFileName: 'IMG_0001.JPG' }],
};
function original(data: Uint8Array, contentType: string) {
return {
stream: chunked(data, 1000),
contentType,
contentLength: String(data.length),
};
}
const download = () =>
GET(new NextRequest('http://localhost/api/download/tok-album/tok-asset'), {
params: Promise.resolve({ album: 'tok-album', id: 'tok-asset' }),
});
beforeEach(() => {
vi.clearAllMocks();
mockConfig.mockReturnValue({ albums: ['album'], albumDownloads: { album: true } });
mockGetAlbum.mockResolvedValue(ALBUM);
});
describe('GET /api/download/[album]/[id]', () => {
it('serves a JPEG without GPS, camera data and length intact', async () => {
const { file } = cameraJpeg(true);
mockStream.mockResolvedValue(original(file, 'image/jpeg'));
const res = await download();
expect(res.status).toBe(200);
expect(res.headers.get('content-length')).toBe(String(file.length));
expect(res.headers.get('content-disposition')).toContain('IMG_0001.JPG');
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(exifOf(body)!);
expect(tiff.gps).toBeNull();
expect(tiff.ifd0.has(0x8825)).toBe(false);
expect(asciiOf(tiff.ifd0.get(0x0110))).toBe('Canon EOS R5');
expect(contains(body, LAT.value(true))).toBe(false);
expect(latin1(body)).not.toMatch(/exif:GPS/);
expect(latin1(body)).toContain('photoshop:City="Berlin"');
});
it('serves a HEIC without GPS', async () => {
const { file } = cameraHeic();
mockStream.mockResolvedValue(original(file, 'image/heic'));
const body = new Uint8Array(await (await download()).arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(heicExif(body)!);
expect(tiff.gps).toBeNull();
expect(asciiOf(tiff.ifd0.get(0x010f))).toBe('Canon');
});
it('passes a format it does not edit through unchanged', async () => {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
mockStream.mockResolvedValue(original(png, 'image/png'));
const body = new Uint8Array(await (await download()).arrayBuffer());
expect(body).toEqual(png);
});
it('refuses a JPEG whose metadata it cannot locate', async () => {
mockStream.mockResolvedValue({
stream: new ReadableStream({
start(controller) {
// SOI, then an APP1 claiming more bytes than the file has.
controller.enqueue(new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]));
controller.close();
},
}),
contentType: 'image/jpeg',
contentLength: '8',
});
const res = await download();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
describe('a photo edited in Immich (#831)', () => {
const EDITED = {
id: 'album',
albumName: 'Album',
assets: [{ id: 'asset', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true }],
};
it('serves the edited rendition, named as the JPEG it is, without GPS', async () => {
mockGetAlbum.mockResolvedValue(EDITED);
// Immich's edited rendition carries no EXIF; one that did must still
// lose its GPS on the way through.
const { file } = cameraJpeg(true);
mockStream.mockResolvedValue(original(file, 'image/jpeg'));
const res = await download();
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith('asset', 'original', true);
expect(res.headers.get('content-type')).toBe('image/jpeg');
expect(res.headers.get('content-disposition')).toContain('IMG_9262.jpg');
expect(res.headers.get('content-disposition')).not.toContain('HEIC');
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
expect(contains(body, LAT.value(true))).toBe(false);
});
it('keeps the name of an edited JPEG', async () => {
mockGetAlbum.mockResolvedValue({
...EDITED,
assets: [{ ...EDITED.assets[0], originalFileName: 'scan.jpeg' }],
});
mockStream.mockResolvedValue(original(cameraJpeg(true).file, 'image/jpeg'));
const res = await download();
expect(res.headers.get('content-disposition')).toContain('scan.jpeg');
});
it('refuses an edited rendition the scrubber would pass through (review of #832)', async () => {
mockGetAlbum.mockResolvedValue(EDITED);
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 ');
mockStream.mockResolvedValue(original(webp, 'image/webp'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const res = await download();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
expect(warn.mock.calls.some((args) => String(args[0]).includes('Refused edited asset'))).toBe(
true,
);
warn.mockRestore();
});
it('asks for the original of a photo that is not edited exactly as before', async () => {
mockStream.mockResolvedValue(original(cameraJpeg(true).file, 'image/jpeg'));
await download();
expect(mockStream).toHaveBeenCalledWith('asset', 'original', false);
});
});
});

View File

@@ -15,6 +15,15 @@
*
* The last one is what stops one enabled album's URL being edited into a
* download of any asset in the Immich instance.
*
* The file goes out as Immich stores it, minus its location: GPS coordinates
* are removed from JPEG, HEIC/HEIF and AVIF originals on the way through
* (lib/locationScrub.ts). Camera data, copyright and colour profile stay.
*
* A photo edited in Immich's editor is the exception (#831): it goes out as
* edited, which is Immich's full-resolution rendition of the edit — a JPEG
* without EXIF — named with a matching extension. It passes the same
* scrubber.
*/
import { NextRequest, NextResponse } from 'next/server';
@@ -24,7 +33,8 @@ import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { isAlbumReachable, siteLockResponse } from '@/lib/auth';
import { contentDisposition } from '@/lib/downloadName';
import { contentDisposition, editedDownloadName } from '@/lib/downloadName';
import { scrubLocationStream } from '@/lib/locationScrub';
export const dynamic = 'force-dynamic';
@@ -104,7 +114,9 @@ export async function GET(
let result;
try {
result = await immich.streamAsset(assetId, 'original');
// A photo edited in Immich goes out as edited (#831): Immich's
// full-resolution edited rendition rather than the unedited camera file.
result = await immich.streamAsset(assetId, 'original', asset.isEdited === true);
} catch (error) {
if (error instanceof ImmichUnavailableError) {
return NextResponse.json(
@@ -116,15 +128,50 @@ export async function GET(
}
if (!result) return notFound();
// Location out, everything else in. The head is read before a byte is sent,
// so a file whose metadata cannot be located is refused rather than leaked.
let scrubbed;
try {
scrubbed = await scrubLocationStream(result.stream as ReadableStream<Uint8Array>);
} catch (error) {
console.error(`[Download] Reading original ${assetId} failed:`, error);
return NextResponse.json(
{ error: 'Immich is currently unavailable' },
{ status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } },
);
}
if (!scrubbed.ok) {
console.warn(
`[Download] Refused original ${assetId}: its location metadata could not be removed (${scrubbed.reason}).`,
);
return notFound();
}
// An edited photo is a rendition Immich made, which Folio expects as JPEG
// (#831). One in a format the scrubber passes through untouched (WebP,
// PNG) is not what was measured, and is refused rather than sent with
// whatever metadata it carries — as the zoom route does.
if (asset.isEdited === true && scrubbed.format === 'passthrough') {
await scrubbed.stream.cancel().catch(() => {});
console.warn(
`[Download] Refused edited asset ${assetId}: Immich sent ${result.contentType || 'no type'}, which is not a JPEG or HEIF-family file.`,
);
return notFound();
}
const headers: Record<string, string> = {
'Content-Type': result.contentType || 'application/octet-stream',
'Content-Disposition': contentDisposition(asset.originalFileName),
'Content-Disposition': contentDisposition(
asset.isEdited === true
? editedDownloadName(asset.originalFileName, result.contentType)
: asset.originalFileName,
),
// Private: a download is authorised per visitor, so a shared cache must
// not hand the file to the next one.
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
};
// Scrubbing overwrites in place, so the upstream length still holds.
if (result.contentLength) headers['Content-Length'] = result.contentLength;
return new NextResponse(result.stream, { headers });
return new NextResponse(scrubbed.stream, { headers });
}

View File

@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import {
LAT,
cameraHeic,
cameraJpeg,
chunked,
contains,
latin1,
} from '@/lib/__tests__/fixtures/location';
/**
* The archive route streams originals as a ZIP for either the whole album
@@ -430,3 +438,103 @@ describe('concurrent archives', () => {
mockClientIp.mockReturnValue('127.0.0.1');
});
});
/**
* The ZIP is stored uncompressed, so each entry's bytes appear in it as they
* are: what the scrubber left is exactly what a visitor unpacks.
*/
describe('location metadata', () => {
it('leaves GPS out of every original in the archive and keeps camera data', async () => {
const jpeg = cameraJpeg(false).file;
const heic = cameraHeic({ le: true }).file;
mockStream.mockImplementation((id: string) =>
Promise.resolve({
stream: chunked(id === 'asset-1' ? jpeg : heic, 512),
contentType: id === 'asset-1' ? 'image/jpeg' : 'image/heic',
contentLength: null,
}),
);
const zip = new Uint8Array(await (await GET(getReq(), params)).arrayBuffer());
expect(contains(zip, LAT.value(false))).toBe(false);
expect(contains(zip, LAT.value(true))).toBe(false);
const text = latin1(zip);
expect(text).not.toMatch(/exif:GPS/);
expect(text).not.toContain('52.520095');
expect(text.split('Canon EOS R5').length - 1).toBeGreaterThanOrEqual(2);
expect(text).toContain('photoshop:City="Berlin"');
});
it('leaves out an original whose metadata cannot be located, and goes on', async () => {
const broken = new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]);
mockStream.mockImplementation((id: string) =>
Promise.resolve(
id === 'asset-1'
? { stream: chunked(broken, 8), contentType: 'image/jpeg', contentLength: null }
: originStream('second-original'),
),
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const zip = latin1(new Uint8Array(await (await GET(getReq(), params)).arrayBuffer()));
// Visible in the server log: which asset, which album, and how many.
const logged = warn.mock.calls.map((args) => String(args[0]));
expect(logged.some((l) => l.includes('asset-1') && l.includes('Test Album'))).toBe(true);
expect(logged.some((l) => l.includes('missing 1 of 2'))).toBe(true);
warn.mockRestore();
expect(zip).not.toContain('photo-1.jpg');
expect(zip).toContain('photo-2.jpg');
expect(zip).toContain('second-original');
});
it('puts a photo edited in Immich in as edited, renamed, and without GPS (#831)', async () => {
mockGetAlbum.mockResolvedValue({
...ALBUM,
assets: [
{ id: 'asset-1', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true },
{ id: 'asset-2', type: 'IMAGE', originalFileName: 'photo-2.jpg' },
],
});
const jpeg = cameraJpeg(true).file;
mockStream.mockImplementation(() =>
Promise.resolve({
stream: chunked(jpeg, 512),
contentType: 'image/jpeg',
contentLength: null,
}),
);
const zip = new Uint8Array(await (await GET(getReq(), params)).arrayBuffer());
expect(mockStream).toHaveBeenCalledWith('asset-1', 'original', true);
expect(mockStream).toHaveBeenCalledWith('asset-2', 'original', false);
const text = latin1(zip);
expect(text).toContain('IMG_9262.jpg');
expect(text).not.toContain('IMG_9262.HEIC');
expect(contains(zip, LAT.value(true))).toBe(false);
});
it('leaves out an edited rendition the scrubber would pass through (review of #832)', async () => {
mockGetAlbum.mockResolvedValue({
...ALBUM,
assets: [
{ id: 'asset-1', type: 'IMAGE', originalFileName: 'IMG_9262.HEIC', isEdited: true },
{ id: 'asset-2', type: 'IMAGE', originalFileName: 'photo-2.jpg' },
],
});
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 edited-webp');
mockStream.mockImplementation((id: string) =>
Promise.resolve(
id === 'asset-1'
? { stream: chunked(webp, 64), contentType: 'image/webp', contentLength: null }
: originStream('second-original'),
),
);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const zip = latin1(new Uint8Array(await (await GET(getReq(), params)).arrayBuffer()));
const logged = warn.mock.calls.map((args) => String(args[0]));
expect(logged.some((l) => l.includes('edited asset asset-1'))).toBe(true);
warn.mockRestore();
expect(zip).not.toContain('edited-webp');
expect(zip).not.toContain('IMG_9262');
expect(zip).toContain('second-original');
});
});

View File

@@ -33,6 +33,9 @@ const STRIP_VERSION = 's1';
*/
const MAX_STRIP_BYTES = 64 * 1024 * 1024;
/** The shape of lib/urls.ts editMarker(): a base-36 timestamp. */
const EDIT_MARKER = /^[0-9a-z]{1,16}$/;
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
const ip = getClientIp(request);
@@ -86,6 +89,18 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
request.nextUrl.searchParams.get('w'),
);
// Photos edited in Immich (#831) are always served as edited: the server
// asks for `edited=true` on every request, whatever the URL says, so a URL
// stripped of its marker cannot bring back what a crop removed. For a photo
// that is not edited Immich returns the same bytes (measured on 3.2), and
// the parameter exists in every supported Immich (since 2.5; Folio needs
// 3.0). The marker `e` that lib/urls.ts writes for edited photos is only a
// cache key: it gives the edited rendition a URL (and ETag) of its own, so
// an `immutable` copy of the unedited one is never reused. A value outside
// the marker's shape is ignored, so it cannot carry anything into the ETag.
const editParam = request.nextUrl.searchParams.get('e');
const edit = editParam && EDIT_MARKER.test(editParam) ? editParam : null;
// ── Browser Cache Optimization ─────────────────────
// Use the opaque token to generate a safe ETag without leaking Immich UUIDs.
// IMAGE_CACHE_VERSION participates so that a bump is not defeated by a client
@@ -93,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// `immutable`, so this only matters after expiry or a cache eviction, but a
// matching ETag across two different URLs would be wrong either way.
const cacheVersion = request.nextUrl.searchParams.get('v') ?? '';
const etag = `W/"${token}-${size}-${STRIP_VERSION}${cacheVersion ? `-${cacheVersion}` : ''}"`;
const etag = `W/"${token}-${size}-${STRIP_VERSION}${edit ? `-e${edit}` : ''}${cacheVersion ? `-${cacheVersion}` : ''}"`;
if (request.headers.get('if-none-match') === etag) {
return new NextResponse(null, {
status: 304,
@@ -107,7 +122,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
let result;
try {
result = await immich.streamAsset(assetId, size);
result = await immich.streamAsset(assetId, size, true);
} catch (error) {
// An outage must not look like a deleted photo. These URLs are served with
// `immutable` on success, and a bare 404 is heuristically cacheable — so
@@ -177,7 +192,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
console.warn(
`[Image API] Original von ${assetId} (${contentType}) nicht strippbar — liefere Preview.`,
);
const preview = await immich.streamAsset(assetId, 'preview');
const preview = await immich.streamAsset(assetId, 'preview', true);
if (!preview) {
return NextResponse.json(
{ error: 'Asset not found' },

View File

@@ -4,9 +4,12 @@ import { GET } from '../[id]/route';
import { encodeAssetId } from '@/lib/tokens';
import { immich, ImmichUnavailableError } from '@/lib/immich';
const published = vi.hoisted(() => ({ value: true }));
const published = vi.hoisted(() => ({ value: true, asked: [] as string[] }));
vi.mock('@/lib/publishedAssets', () => ({
isPublishedAsset: async () => published.value,
isPublishedAsset: async (id: string) => {
published.asked.push(id);
return published.value;
},
}));
vi.mock('@/lib/admin/auth', () => ({
isAdminAuthenticated: async () => false,
@@ -77,7 +80,72 @@ describe('GET /api/image/[id]', () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID));
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
});
describe('photos edited in Immich (#831)', () => {
beforeEach(() => {
published.asked = [];
});
it('asks Immich for the edit when the URL carries the edit marker', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID), '?size=preview&e=mf3k2abc');
expect(res.status).toBe(200);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
});
it('asks for the edited thumbnail too', async () => {
mockStream.mockResolvedValue(fakeBody('image/webp'));
await call(encodeAssetId(ASSET_ID), '?size=thumbnail&e=mf3k2abc');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'thumbnail', true);
});
it('shows the edit to a URL stripped of its marker: the server decides, not the URL', async () => {
// Review of #832: with the marker as the switch, dropping `&e=` from a
// URL brought back what the photographer had cropped out.
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
await call(encodeAssetId(ASSET_ID), '?size=preview&w=1440');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
mockStream.mockResolvedValue(fakeBody('image/webp'));
await call(encodeAssetId(ASSET_ID), '?size=thumbnail');
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'thumbnail', true);
expect(mockStream).not.toHaveBeenCalledWith(ASSET_ID, expect.anything(), false);
});
it('is still gated by the published-asset check, which goes by the asset alone', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const res = await call(encodeAssetId(ASSET_ID), '?size=preview&e=mf3k2abc&w=1440');
expect(res.status).toBe(200);
expect(published.asked).toEqual([ASSET_ID]);
published.value = false;
try {
const refused = await call(encodeAssetId(ASSET_ID), '?size=preview&e=mf3k2abc');
expect(refused.status).toBe(404);
} finally {
published.value = true;
}
});
it('gives the edited rendition its own ETag', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const token = encodeAssetId(ASSET_ID);
const plain = (await call(token, '?size=preview')).headers.get('ETag');
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const edited = (await call(token, '?size=preview&e=mf3k2abc')).headers.get('ETag');
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const reEdited = (await call(token, '?size=preview&e=mf9zz001')).headers.get('ETag');
expect(new Set([plain, edited, reEdited]).size).toBe(3);
});
it('ignores a marker that is not one, and keeps it out of the ETag', async () => {
mockStream.mockResolvedValue(fakeBody('image/jpeg'));
const token = encodeAssetId(ASSET_ID);
const res = await call(token, `?size=preview&e=${encodeURIComponent('"x", W/"y')}`);
expect(mockStream).toHaveBeenCalledWith(ASSET_ID, 'preview', true);
expect(res.headers.get('ETag')).not.toContain('y');
});
});
// Regression guard for the stored-XSS fix in c2fa8e7. An SVG served as

View File

@@ -3,21 +3,13 @@ import { revalidatePath } from 'next/cache';
import { completeInstall, isInstalled, normalizeApiBase, validateSetupToken } from '@/lib/install';
import { invalidateConfigCache } from '@/lib/config';
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { PRESET_IDS } from '@/lib/config/presets';
import { immich } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** Install submissions per minute per IP — only live before install. */
const INSTALL_RPM = 10;
const THEME_PRESETS = [
'studio-modern',
'studio',
'minimal',
'editorial',
'classic',
'noir',
'monograph',
];
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** POST: run the install wizard's final step — verify, then write config. */
@@ -59,7 +51,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid Immich URL' }, { status: 400 });
}
const theme = THEME_PRESETS.includes(body.theme) ? body.theme : DEFAULT_PRESET;
const theme = PRESET_IDS.includes(body.theme) ? body.theme : DEFAULT_PRESET;
// Album selection is optional: an empty gallery is a valid install.
const rawAlbums: unknown[] = Array.isArray(body.albums) ? (body.albums as unknown[]) : [];

View File

@@ -140,7 +140,10 @@ export async function GET(request: NextRequest) {
lat: position.lat,
lng: position.lng,
photoCount,
coverUrl: imageUrl(allowedAlbums[0].coverAssetId, 'thumbnail'),
coverUrl: imageUrl(
{ id: allowedAlbums[0].coverAssetId, ...allowedAlbums[0].coverEdit },
'thumbnail',
),
albums: allowedAlbums.map((a) => ({
name: a.name,
url: a.subpageSlug ? `/${a.subpageSlug}/${a.slug}` : `/${a.slug}`,

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest, NextResponse } from 'next/server';
import {
LAT,
asciiOf,
cameraHeic,
cameraJpeg,
chunked,
contains,
exifOf,
heicExif,
latin1,
parseTiff,
} from '@/lib/__tests__/fixtures/location';
/**
* GET /api/zoom/[album]/[id] (#467): the full-resolution file behind the
* lightbox zoom. Every refusal is the same 404; the route checks are tested
* here, the per-route zoom/password resolution in lib/__tests__/auth-zoom.
*/
vi.mock('@/lib/config', () => ({ getConfig: vi.fn() }));
vi.mock('@/lib/tokens', () => ({
decodeAssetId: vi.fn((t: string) => (t.startsWith('tok-') ? t.replace('tok-', '') : null)),
}));
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: vi.fn(), streamAsset: vi.fn(), streamFullsize: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
}));
vi.mock('@/lib/rate-limit', () => ({
checkRateLimit: vi.fn(() => ({ success: true, resetAt: 0 })),
getClientIp: vi.fn(() => '127.0.0.1'),
retryAfterSeconds: vi.fn(() => 60),
}));
vi.mock('@/lib/auth', () => ({
siteLockResponse: vi.fn(() => null),
isAlbumZoomReachable: vi.fn(() => true),
}));
vi.mock('@/lib/publishedAssets', () => ({ isPublishedAsset: vi.fn(async () => true) }));
vi.mock('next/headers', () => ({
cookies: vi.fn(() => Promise.resolve({ get: () => undefined })),
}));
import { GET } from '../route';
import { getConfig } from '@/lib/config';
import { immich, ImmichUnavailableError } from '@/lib/immich';
import { checkRateLimit } from '@/lib/rate-limit';
import { isAlbumZoomReachable, siteLockResponse } from '@/lib/auth';
import { isPublishedAsset } from '@/lib/publishedAssets';
const mocked = <T>(fn: T) => fn as unknown as ReturnType<typeof vi.fn>;
const asset = (id: string, originalMimeType: string, type = 'IMAGE') => ({
id,
type,
originalMimeType,
originalFileName: `${id}.bin`,
});
const ALBUM = {
id: 'album',
albumName: 'Album',
assets: [
asset('jpeg', 'image/jpeg'),
asset('avif', 'image/avif'),
asset('heic', 'image/heic'),
asset('png', 'image/png'),
asset('webp', 'image/webp'),
asset('mov', 'video/quicktime', 'VIDEO'),
{ ...asset('edited', 'image/jpeg'), isEdited: true },
{ ...asset('edited-heic', 'image/heic'), isEdited: true },
],
};
function upstream(data: Uint8Array, contentType: string) {
return { stream: chunked(data, 1000), contentType, contentLength: String(data.length) };
}
const zoom = (assetToken = 'tok-jpeg', albumToken = 'tok-album') =>
GET(new NextRequest(`http://localhost/api/zoom/${albumToken}/${assetToken}`), {
params: Promise.resolve({ album: albumToken, id: assetToken }),
});
/** A HEIF-family file branded as AVIF, as Lightroom writes one. */
function avifFile() {
const { file } = cameraHeic();
const out = file.slice();
out.set([0x61, 0x76, 0x69, 0x66], 8); // major brand → 'avif'
return out;
}
beforeEach(() => {
vi.clearAllMocks();
mocked(getConfig).mockReturnValue({ albums: ['album'] });
mocked(immich.getAlbum).mockResolvedValue(ALBUM);
});
describe('refusals', () => {
it('answers a locked site with the lock, before anything else', async () => {
mocked(siteLockResponse).mockReturnValueOnce(
NextResponse.json(
{ error: 'locked' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } },
),
);
const res = await zoom();
expect(res.status).toBe(401);
expect(immich.getAlbum).not.toHaveBeenCalled();
});
it('rate-limits in its own bucket', async () => {
mocked(checkRateLimit).mockReturnValueOnce({ success: false, resetAt: Date.now() + 1000 });
const res = await zoom();
expect(res.status).toBe(429);
expect(res.headers.get('cache-control')).toBe('no-store');
expect(res.headers.get('retry-after')).toBe('60');
expect(checkRateLimit).toHaveBeenCalledWith('zoom:127.0.0.1', 20);
});
it.each([
['an undecodable album token', () => zoom('tok-jpeg', 'garbage')],
['an undecodable asset token', () => zoom('garbage')],
])('refuses %s', async (_name, call) => {
const res = await call();
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
it('refuses an album that is not on the allowlist', async () => {
mocked(getConfig).mockReturnValue({ albums: ['other'] });
expect((await zoom()).status).toBe(404);
expect(isAlbumZoomReachable).not.toHaveBeenCalled();
});
it('refuses when zoom is off, or the album is locked, hidden behind an offline page …', async () => {
// isAlbumZoomReachable covers each of those (lib/__tests__/auth-zoom).
mocked(isAlbumZoomReachable).mockReturnValueOnce(false);
const res = await zoom();
expect(res.status).toBe(404);
expect(isAlbumZoomReachable).toHaveBeenCalledWith('album', expect.any(Function));
expect(immich.streamAsset).not.toHaveBeenCalled();
});
it('refuses an asset the site no longer publishes', async () => {
mocked(isPublishedAsset).mockResolvedValueOnce(false);
expect((await zoom()).status).toBe(404);
expect(immich.getAlbum).not.toHaveBeenCalled();
});
it('refuses an asset from another album', async () => {
expect((await zoom('tok-elsewhere')).status).toBe(404);
expect(immich.streamAsset).not.toHaveBeenCalled();
});
it.each(['png', 'webp', 'mov'])('never streams a %s', async (id) => {
expect((await zoom(`tok-${id}`)).status).toBe(404);
expect(immich.streamAsset).not.toHaveBeenCalled();
expect(immich.streamFullsize).not.toHaveBeenCalled();
});
it('answers 503 while Immich is down', async () => {
mocked(immich.getAlbum).mockRejectedValueOnce(new ImmichUnavailableError('down'));
const res = await zoom();
expect(res.status).toBe(503);
expect(res.headers.get('cache-control')).toBe('no-store');
});
});
describe('the JPEG original', () => {
it('goes out without GPS, inline, privately cached, length intact', async () => {
const { file } = cameraJpeg(true);
mocked(immich.streamAsset).mockResolvedValue(upstream(file, 'image/jpeg'));
const res = await zoom();
expect(res.status).toBe(200);
expect(immich.streamAsset).toHaveBeenCalledWith('jpeg', 'original');
expect(immich.streamFullsize).not.toHaveBeenCalled();
expect(res.headers.get('content-type')).toBe('image/jpeg');
expect(res.headers.get('content-disposition')).toBe('inline');
expect(res.headers.get('x-content-type-options')).toBe('nosniff');
expect(res.headers.get('cache-control')).toBe('private, max-age=3600');
expect(res.headers.get('content-length')).toBe(String(file.length));
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
const tiff = parseTiff(exifOf(body)!);
expect(tiff.gps).toBeNull();
expect(asciiOf(tiff.ifd0.get(0x0110))).toBe('Canon EOS R5');
expect(contains(body, LAT.value(true))).toBe(false);
expect(latin1(body)).not.toMatch(/exif:GPS/);
});
it('is refused when its metadata cannot be located', async () => {
mocked(immich.streamAsset).mockResolvedValue({
stream: chunked(new Uint8Array([0xff, 0xd8, 0xff, 0xe1, 0x40, 0x00, 0x45, 0x78]), 8),
contentType: 'image/jpeg',
contentLength: '8',
});
expect((await zoom()).status).toBe(404);
});
it('is refused when the bytes are not a JPEG at all', async () => {
// Labelled JPEG, actually a PNG: the scrubber would pass it through
// untouched, eXIf chunk and all.
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
mocked(immich.streamAsset).mockResolvedValue(upstream(png, 'image/jpeg'));
expect((await zoom()).status).toBe(404);
});
});
describe('the AVIF original', () => {
it('goes out without GPS', async () => {
const file = avifFile();
mocked(immich.streamAsset).mockResolvedValue(upstream(file, 'image/avif'));
const res = await zoom('tok-avif');
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/avif');
const body = new Uint8Array(await res.arrayBuffer());
expect(body.length).toBe(file.length);
expect(parseTiff(heicExif(body)!).gps).toBeNull();
});
});
describe('a HEIC (not browser-displayable)', () => {
it('uses Immich’s full-size rendition, scrubbed', async () => {
const { file } = cameraJpeg(false);
mocked(immich.streamFullsize).mockResolvedValue(upstream(file, 'image/jpeg'));
const res = await zoom('tok-heic');
expect(res.status).toBe(200);
expect(immich.streamFullsize).toHaveBeenCalledWith('heic', false);
expect(immich.streamAsset).not.toHaveBeenCalled();
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
});
it('is 404 when Immich has no rendition', async () => {
mocked(immich.streamFullsize).mockResolvedValue(null);
const res = await zoom('tok-heic');
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
it('never sends the HEIC original itself', async () => {
mocked(immich.streamFullsize).mockResolvedValue(upstream(cameraHeic().file, 'image/heic'));
expect((await zoom('tok-heic')).status).toBe(404);
});
it('refuses a WebP rendition, whose metadata is not scrubbed', async () => {
const webp = new TextEncoder().encode('RIFF\x10\x00\x00\x00WEBPVP8 ');
mocked(immich.streamFullsize).mockResolvedValue(upstream(webp, 'image/webp'));
expect((await zoom('tok-heic')).status).toBe(404);
});
});
describe('a photo edited in Immich (#831)', () => {
it.each(['edited', 'edited-heic'])(
'zooms a %s into the edited full-size rendition, never the original',
async (id) => {
// Immich's edited rendition is a JPEG; given one with GPS here, the
// scrubber still has to take it out.
const { file } = cameraJpeg(true);
mocked(immich.streamFullsize).mockResolvedValue(upstream(file, 'image/jpeg'));
const res = await zoom(`tok-${id}`);
expect(res.status).toBe(200);
expect(immich.streamFullsize).toHaveBeenCalledWith(id, true);
expect(immich.streamAsset).not.toHaveBeenCalled();
const body = new Uint8Array(await res.arrayBuffer());
expect(parseTiff(exifOf(body)!).gps).toBeNull();
expect(contains(body, LAT.value(true))).toBe(false);
},
);
it('is 404 when Immich has no edited rendition', async () => {
mocked(immich.streamFullsize).mockResolvedValue(null);
const res = await zoom('tok-edited');
expect(res.status).toBe(404);
expect(res.headers.get('cache-control')).toBe('no-store');
});
});

View File

@@ -0,0 +1,181 @@
/**
* The full-resolution image behind the lightbox zoom (#467).
*
* Shaped like the download route, and for the same reason: this hands out a
* file at full resolution, so it re-checks everything instead of treating the
* asset token as the capability the image proxy does.
*
* - the album must be on the allowlist,
* - the visitor must be able to reach it by a route on which zoom resolves
* on — album, then subpage, then `zoom` in settings.yaml — with every
* password gate on that route satisfied,
* - the asset must currently be published and belong to that album, and
* - it must have a zoomable source (lib/zoomSource.ts).
*
* What it sends:
*
* - JPEG and AVIF originals as they are stored, minus their location: GPS and
* sub-city place names are removed in place on the way through
* (lib/locationScrub.ts), so the length still holds.
* - For formats a browser cannot show (HEIC, RAW, TIFF, …), Immich's
* full-size JPEG rendition, through the same scrubber. When Immich has
* none — full-size previews switched off, or the asset not yet processed —
* the answer is 404 and the lightbox stays on the preview.
* - For a photo edited in Immich, whatever its format, the edited full-size
* rendition (#831), through the same scrubber, so the zoom shows the crop
* and rotation the preview shows.
*
* Whatever the source, only a file the scrubber recognised and cleaned goes
* out, and only as JPEG or AVIF. A format it passes through untouched (a PNG
* labelled as JPEG, a WebP rendition) is refused rather than sent with
* whatever metadata it carries.
*/
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
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 { isAlbumZoomReachable, siteLockResponse } from '@/lib/auth';
import { isPublishedAsset } from '@/lib/publishedAssets';
import { scrubLocationStream } from '@/lib/locationScrub';
import { ZOOM_CONTENT_TYPES, zoomSourceFor } from '@/lib/zoomSource';
export const dynamic = 'force-dynamic';
/**
* Per client IP and minute. A zoom is one deliberate gesture per photo, and
* each answer is a full-resolution file off the Immich server — 7 MB for a
* 60 MP JPEG. Twenty a minute is a zoom every three seconds, sustained, which
* a visitor checking focus does not reach; a script pulling originals does.
* Below the download route's 30: a download is rarer still, but zooming is
* offered on whole albums that never opted into downloads.
*/
const ZOOM_RPM = 20;
const NO_STORE = { 'Cache-Control': 'no-store' };
/** One 404 for every refusal, so the response never says which check failed. */
const notFound = () =>
NextResponse.json({ error: 'Not found' }, { status: 404, headers: NO_STORE });
const unavailable = () =>
NextResponse.json(
{ error: 'Immich is currently unavailable' },
{ status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } },
);
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ album: string; id: string }> },
) {
const ip = getClientIp(request);
const rl = checkRateLimit(`zoom:${ip}`, ZOOM_RPM);
if (!rl.success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: { ...NO_STORE, 'Retry-After': String(retryAfterSeconds(rl.resetAt)) },
},
);
}
const locked = siteLockResponse(request);
if (locked) return locked;
const { album: albumToken, id: assetToken } = await params;
const albumId = decodeAssetId(albumToken);
const assetId = decodeAssetId(assetToken);
if (!albumId || !assetId) return notFound();
const config = getConfig();
if (!config.albums.includes(albumId)) return notFound();
// Zoom on, and a route to the album the visitor could have taken. A route
// handler is reached without passing through the page, so the page's gates
// are asked again here.
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
if (!isAlbumZoomReachable(albumId, getCookie)) return notFound();
// Same membership test as the image proxy: an album taken offline or an
// asset removed from it stops being served at once, not after the cache.
if (!(await isPublishedAsset(assetId))) return notFound();
let album;
try {
album = await immich.getAlbum(albumId);
} catch (error) {
if (error instanceof ImmichUnavailableError) return unavailable();
throw error;
}
if (!album) return notFound();
// The asset has to be in the album that authorised the zoom.
const asset = album.assets.find((candidate) => candidate.id === assetId);
if (!asset) return notFound();
const source = zoomSourceFor(asset);
if (!source) return notFound();
let result;
try {
// An edited photo (#831) is zoomed into Immich's edited full-size
// rendition, never its original, which is the unedited file. Redirects
// are not followed here either: should Immich send one, it would be to a
// smaller rendition, which must not pass for full resolution.
result =
source === 'original'
? await immich.streamAsset(assetId, 'original')
: await immich.streamFullsize(assetId, source === 'edited');
} catch (error) {
if (error instanceof ImmichUnavailableError) return unavailable();
throw error;
}
// For `fullsize` this is the common "Immich has no rendition" case.
if (!result) return notFound();
const contentType = result.contentType.toLowerCase().split(';')[0].trim();
if (!ZOOM_CONTENT_TYPES.has(contentType)) {
await (result.stream as ReadableStream).cancel().catch(() => {});
console.warn(`[Zoom] Refused ${assetId}: Immich sent ${contentType || 'no type'}.`);
return notFound();
}
let scrubbed;
try {
scrubbed = await scrubLocationStream(result.stream as ReadableStream<Uint8Array>);
} catch (error) {
console.error(`[Zoom] Reading ${source} ${assetId} failed:`, error);
return unavailable();
}
if (!scrubbed.ok) {
console.warn(
`[Zoom] Refused ${assetId}: its location metadata could not be removed (${scrubbed.reason}).`,
);
return notFound();
}
if (scrubbed.format === 'passthrough') {
// Labelled JPEG/AVIF, but not a file the scrubber knows how to clean.
await scrubbed.stream.cancel().catch(() => {});
console.warn(`[Zoom] Refused ${assetId}: not a JPEG or HEIF-family file.`);
return notFound();
}
const headers: Record<string, string> = {
'Content-Type': contentType,
// Shown in the page, never saved by a click.
'Content-Disposition': 'inline',
// Private: the answer depends on this visitor's cookies, so a shared cache
// (a CDN, a proxy) must not hand it to the next one. An hour in the
// visitor's own cache makes a second zoom into the same photo free.
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff',
};
// Scrubbing overwrites in place, so the upstream length still holds.
if (result.contentLength) headers['Content-Length'] = result.contentLength;
return new NextResponse(scrubbed.stream, { headers });
}

View File

@@ -45,18 +45,14 @@
width: 100%;
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border: 1px solid var(--input-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 1rem;
transition: border-color var(--transition-fast);
}
.contact-form__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-color: var(--accent);
}
/* The ring itself comes from the global focus rule (app/tokens.css), the
same one every link and button draws. */
textarea.contact-form__input {
min-height: 10rem;
resize: vertical;

View File

@@ -5,12 +5,22 @@
* 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.
*
* The legal notice and the privacy policy are the exception: the proxy serves
* them to a locked site, and this page links them, so a visitor who cannot get
* in can still see who runs the site.
*
* /contact is served to a locked site as well, but not linked from here: it is
* open because the Impressum names it as a contact channel, and the Impressum
* links it. A form under the password field would read as "ask for access".
*/
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { isSiteLocked, SITE_AUTH_KEY } from '@/lib/auth';
import { privacyAvailable } from '@/lib/privacy';
import { getServerDictionary } from '@/lib/i18n/server';
import PasswordGate from '@/components/PasswordGate';
export const dynamic = 'force-dynamic';
@@ -24,5 +34,20 @@ export default function GatePage() {
// password configured there is no gate to show.
if (!isSiteLocked()) notFound();
return <PasswordGate slug={SITE_AUTH_KEY} title={getConfig().siteTitle} type="site" />;
const config = getConfig();
const t = getServerDictionary();
// Only the pages that exist: a link to a switched-off one would be a 404.
const legalLinks = [
...(config.legal.enabled ? [{ href: '/impressum', label: t.legal.navLabel }] : []),
...(privacyAvailable(config) ? [{ href: '/privacy', label: t.privacy.navLabel }] : []),
];
return (
<PasswordGate
slug={SITE_AUTH_KEY}
title={config.siteTitle}
type="site"
legalLinks={legalLinks}
/>
);
}

View File

@@ -5,6 +5,11 @@
@import url('./themes/noir.css');
@import url('./themes/monograph.css');
@import url('./themes/studio-modern.css');
@import url('./themes/kunsthalle.css');
@import url('./themes/ma.css');
@import url('./themes/cyanotype.css');
@import url('./themes/salon.css');
@import url('./themes/birch.css');
@import url('./tokens.css');
@import url('./leaflet.css');
@@ -27,7 +32,7 @@
.skip-link:focus {
top: 0;
outline: 2px solid var(--text-primary);
outline: 2px solid var(--focus-ring);
}
/* ── Screen-reader-only text ──────────────────────── */
@@ -207,6 +212,32 @@
/* 56px header + 48px original padding */
}
/* Sticky footer: on a short page (a journal with two entries, the 404) the
footer used to end wherever the content did, floating mid-viewport. The
visitor chrome is a column whose <main> takes the spare height. Scoped to
pages that carry that chrome — the admin, the setup screen and the site
gate render straight into <body> and keep their own layout. The header,
skip link, lightbox and dialogs are fixed or absolute, so they stay out of
the column. */
body:has(> #main-content) {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
}
body:has(> #main-content) > .main {
flex: 1 0 auto;
/* A flex item with auto side margins shrinks to its content; the width
puts it back to full width, --max-width still caps it. */
width: 100%;
}
body:has(> #main-content) > .footer {
flex-shrink: 0;
width: 100%;
}
/* Simple CSS Page Fade-In (Replaces Framer Motion).
*
* Opacity only — no `transform`. A transform on `.main` (even the identity
@@ -391,6 +422,12 @@
display: none;
}
/* The aperture sign inside EXIF lines (components/ExifText.tsx). Presets that
set EXIF in capitals would otherwise print "Ƒ/4". */
.exif-fsign {
text-transform: none;
}
/* ── Hero: Fullbleed Layout ─────────────────────── */
.hero--fullbleed {
position: relative;
@@ -1537,6 +1574,13 @@
opacity: 1;
}
/* The wall label (PhotoGrid's figcaption): number, caption, EXIF under the
photo. Hidden unless a preset hangs its photos with one (kunsthalle), so
every other preset lays out as if it were not there. */
.photo-grid__label {
display: none;
}
/* Hover-only overlays leave keyboard and touch visitors out (#696). A tile
with keyboard focus shows its overlay like a hovered one, and on devices
without hover — phones, and tablets wider than the 640px mobile rules — the
@@ -1881,38 +1925,94 @@
flex-shrink: 0;
}
/* ─ Passepartout photo frame ─ */
[data-photo-frame='passepartout'] .photo-grid__item {
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 8px 8px 24px;
margin-bottom: var(--grid-gap, 24px);
background: #111;
/* ─ Photo box inside a grid tile ─ */
/* Covers the tile (the box next/image `fill` used to take from the tile
itself) unless a frame sets the mat variables. Clips the hover zoom to the
photo, so it cannot spill onto a mat. */
.photo-grid__media {
position: absolute;
inset: var(--photo-mat, 0px) var(--photo-mat, 0px) var(--photo-mat-caption, 0px);
overflow: hidden;
}
[data-photo-frame='passepartout'][data-theme='light'] .photo-grid__item {
border-color: rgba(0, 0, 0, 0.1);
background: #fff;
/* ─ Passepartout photo frame ─ */
/* A mat board around each photo, with the EXIF line printed in the wider
bottom band like the caption under a print: always shown, never over the
photo. The tile is the mat; PhotoGrid's .photo-grid__media holds the photo
and is inset by the mat below (without a frame it covers the tile, the
variables being unset). The geometry lives in these variables so the grain
overlay and the justified row math (lib/justifiedRow.ts) inset by exactly
the same amounts. Colours are the --mat-* tokens in tokens.css. */
[data-photo-frame='passepartout'] .photo-grid {
--photo-mat: 8px;
--photo-mat-caption: 32px;
--photo-mat-border: 1px;
/* Everything around the photo, border included, across and down. */
--photo-mat-x: calc(2 * (var(--photo-mat) + var(--photo-mat-border)));
--photo-mat-y: calc(var(--photo-mat) + var(--photo-mat-caption) + 2 * var(--photo-mat-border));
}
[data-photo-frame='passepartout'] .photo-grid__item-exif {
position: relative;
bottom: auto;
left: auto;
right: auto;
/* Also reaches the essay images, which share the class but have no mat. */
[data-photo-frame='passepartout'] .photo-grid__item {
border: 1px solid var(--mat-border);
padding: 8px 8px 24px;
margin-bottom: var(--grid-gap, 24px);
background: var(--mat-bg);
overflow: hidden;
}
[data-photo-frame='passepartout'] .photo-grid .photo-grid__item {
/* The aspect ratio a layout gives the tile (the photo's own in masonry) has
to size the photo, not the photo plus its mat. */
box-sizing: content-box;
border-width: var(--photo-mat-border);
padding: var(--photo-mat) var(--photo-mat) var(--photo-mat-caption);
}
/* The rule above re-adds the masonry row spacing; these layouts space their
rows with the grid or flex gap, and doubled it under this frame. */
[data-photo-frame='passepartout'] .photo-grid--uniform .photo-grid__item,
[data-photo-frame='passepartout'] .photo-grid--showcase .photo-grid__item,
[data-photo-frame='passepartout'] .photo-grid--filmstrip .photo-grid__item,
[data-photo-frame='passepartout'] .photo-grid--editorial-flow .photo-grid__item,
[data-photo-frame='passepartout'] .photo-grid--justified .photo-grid__item {
margin-bottom: 0;
}
/* Justified tiles fill a wrapper whose size already counts the mat. */
[data-photo-frame='passepartout'] .photo-grid--justified .photo-grid__item {
box-sizing: border-box;
}
[data-photo-frame='passepartout'] .photo-grid .photo-grid__item-play {
inset: var(--photo-mat) var(--photo-mat) var(--photo-mat-caption);
}
/* Specific enough to beat the presets' own EXIF styles (noir, monograph,
studio-modern), which are overlays meant for a frameless tile. */
[data-photo-frame='passepartout'] .photo-grid .photo-grid__item-exif {
display: block;
position: absolute;
top: auto;
left: var(--photo-mat);
right: var(--photo-mat);
bottom: 0;
height: var(--photo-mat-caption);
line-height: var(--photo-mat-caption);
padding: 0;
background: none;
padding: 8px 0 0;
color: var(--text-muted);
-webkit-backdrop-filter: none;
backdrop-filter: none;
transform: none;
color: var(--mat-text);
font-family: var(--font-caption);
font-style: italic;
font-size: 0.75rem;
letter-spacing: 0.02em;
white-space: normal;
opacity: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
[data-photo-frame='passepartout'] .photo-grid__item:hover .photo-grid__item-exif {
opacity: 1;
}
@@ -1928,13 +2028,14 @@
}
/* ─ Film grain overlay ─ */
/* Over the photo only: the passepartout mat variables, 0 without a frame. */
[data-grain='true'] .photo-grid__item::after {
content: '';
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 24px;
top: var(--photo-mat, 0px);
left: var(--photo-mat, 0px);
right: var(--photo-mat, 0px);
bottom: var(--photo-mat-caption, 0px);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
background-size: 128px 128px;
pointer-events: none;
@@ -1942,15 +2043,6 @@
mix-blend-mode: overlay;
}
/* Grain without passepartout — fill the whole item */
[data-grain='true'][data-photo-frame='none'] .photo-grid__item::after,
[data-grain='true'][data-photo-frame='shadow'] .photo-grid__item::after {
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* ── Gallery Aesthetics (all themes) ─────────────── */
/* Generous spacing — gallery exhibition feel */
@@ -2015,7 +2107,7 @@
.hero__nav-link:hover {
border-bottom-color: var(--accent);
color: var(--accent);
color: var(--accent-text);
}
/* Album cards */
@@ -2045,17 +2137,17 @@
/* Accent hover effects (works with any accent color) */
.header__nav-link:hover,
.header__nav-link.active {
color: var(--accent);
color: var(--accent-text);
border-bottom-color: var(--accent);
}
.footer__link:hover {
color: var(--accent);
color: var(--accent-text);
}
.scroll-to-top:hover {
border-color: var(--accent);
color: var(--accent);
color: var(--accent-text);
}
/* ── Tablet (iPad, kleine Laptops) ─────────────────── */
@@ -2151,8 +2243,13 @@
opacity: 1;
}
[data-photo-frame='passepartout'] .photo-grid__item {
padding: 6px 6px 18px;
[data-photo-frame='passepartout'] .photo-grid {
--photo-mat: 6px;
--photo-mat-caption: 26px;
}
[data-photo-frame='passepartout'] .photo-grid .photo-grid__item-exif {
font-size: 0.6875rem;
}
/* Minimal: adjust fullbleed hero for mobile */

View File

@@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
// 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 { PRESET_REGISTRY } from '@/lib/config/presets';
import '../admin/admin.css';
import './install.css';
@@ -22,19 +23,11 @@ interface Props {
const STEPS = ['Connect', 'Albums', 'Site', 'Finish'] as const;
const THEME_OPTIONS = [
{
value: 'studio-modern',
label: 'Studio Modern',
description: 'Precision grotesque, hairline rules',
},
{ value: 'studio', label: 'Studio', description: 'Classic portfolio, red accent' },
{ value: 'minimal', label: 'Minimal', description: 'Bare-bones, monochrome' },
{ value: 'editorial', label: 'Editorial', description: 'Magazine-style serif' },
{ value: 'classic', label: 'Classic', description: 'Warm gold, traditional' },
{ value: 'noir', label: 'Noir', description: 'Dark, high-contrast' },
{ value: 'monograph', label: 'Monograph', description: 'Typographic, text-first' },
];
const THEME_OPTIONS = PRESET_REGISTRY.map((p) => ({
value: p.id,
label: p.label,
description: p.tagline,
}));
export function InstallWizard({ initialApiUrl, setupToken }: Props) {
const [step, setStep] = useState(0);

View File

@@ -9,12 +9,14 @@ import { isAuthenticated } from '@/lib/auth';
import { journalNeighbours } from '@/lib/journalNav';
import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls';
import { immich } from '@/lib/immich';
import { EssayView } from '@/app/[...path]/EssayView';
import { JournalNav } from '@/components/JournalNav';
import PasswordGate from '@/components/PasswordGate';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
import crypto from 'crypto';
import '../journal.css';
export const dynamic = 'force-dynamic';
@@ -50,12 +52,18 @@ export async function generateMetadata({ params }: JournalDetailPageProps): Prom
const title = blocked ? t.journal.title : frontmatter.title || slug;
const description = blocked
? t.journal.description
? t.journal.subtitle
: frontmatter.subtitle || t.journal.entryDescription;
// The cover's asset for its edit marker (#831); the page body asks for the
// same one, from the same cache.
const coverAsset =
!blocked && frontmatter.coverAssetId
? await immich.getAssetInfo(frontmatter.coverAssetId).catch(() => null)
: null;
const ogImages =
!blocked && frontmatter.coverAssetId
? [{ url: imageUrl(frontmatter.coverAssetId, 'preview') }]
? [{ url: imageUrl(coverAsset ?? frontmatter.coverAssetId, 'preview') }]
: [];
return {
@@ -178,6 +186,14 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
<div style={{ paddingTop: '2rem' }}>
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '0 1.5rem 1rem' }}>
<BackLink href="/journal" label={getServerDictionary().common.backToJournal} />
{/* Only an admin gets this far with a draft. The index marks it; the
entry itself looked published, which is exactly what a draft
preview must not look like. */}
{frontmatter.draft && (
<span className="journal-card__draft-badge" style={{ marginLeft: '1rem' }}>
{getServerDictionary().journal.draft}
</span>
)}
</div>
<EssayView
essay={essayForClient}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ReactElement, ReactNode } from 'react';
/**
* P-17: /journal shared as the site's home page (no openGraph of its own), and
* its meta description was a sentence the page never showed.
* P-21: a draft entry, which only an admin can open, looked published — the
* index marks drafts, the entry page did not.
*/
const readJournalEntry = vi.fn();
let admin = false;
vi.mock('@/lib/admin/journal-service', () => ({
readJournalEntry: (slug: string) => readJournalEntry(slug),
listJournalEntries: async () => [],
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => admin }));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('next/navigation', () => ({
notFound: () => {
throw new Error('notFound');
},
}));
vi.mock('@/lib/config', () => ({
getConfig: () => ({ authSecret: 'test-secret', watermark: undefined }),
getConfigOrNull: () => ({ lang: 'en' }),
}));
vi.mock('@/app/[...path]/essayPayload', () => ({
buildEssayPayload: async () => ({
essay: { frontmatter: {}, blocks: [] },
images: [],
coverToken: undefined,
}),
}));
vi.mock('@/app/[...path]/EssayView', () => ({ EssayView: () => null }));
vi.mock('@/components/JournalNav', () => ({ JournalNav: () => null }));
vi.mock('@/components/BackLink', () => ({ BackLink: () => null }));
vi.mock('@/components/PasswordGate', () => ({ default: function PasswordGate() {} }));
const { default: JournalDetailPage } = await import('../[slug]/page');
const { generateMetadata: indexMetadata } = await import('../page');
const { getServerDictionary } = await import('@/lib/i18n/server');
/** Every string rendered anywhere in an element tree. */
function texts(node: ReactNode): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return [];
if (typeof node === 'string' || typeof node === 'number') return [String(node)];
if (Array.isArray(node)) return node.flatMap(texts);
const el = node as ReactElement<{ children?: ReactNode }>;
return texts(el.props?.children);
}
describe('/journal metadata', () => {
it('shares as the journal, with the subtitle the page shows', () => {
const t = getServerDictionary();
const m = indexMetadata();
expect(m.title).toBe(t.journal.title);
expect(m.description).toBe(t.journal.subtitle);
expect(m.openGraph?.title).toBe(t.journal.title);
expect(m.openGraph?.description).toBe(t.journal.subtitle);
const images = m.openGraph?.images as string[];
expect(images).toHaveLength(1);
expect(new URL(images[0], 'https://x.test').searchParams.get('title')).toBe(t.journal.title);
});
});
describe('journal entry draft badge', () => {
beforeEach(() => {
admin = true;
readJournalEntry.mockReset();
});
const render = async () =>
texts((await JournalDetailPage({ params: Promise.resolve({ slug: 'trip' }) })) as ReactElement);
it('marks a draft for the admin previewing it', async () => {
readJournalEntry.mockResolvedValue({
slug: 'trip',
parsed: { frontmatter: { title: 'Trip', draft: true }, blocks: [] },
});
expect(await render()).toContain(getServerDictionary().journal.draft);
});
it('does not mark a published entry', async () => {
readJournalEntry.mockResolvedValue({
slug: 'trip',
parsed: { frontmatter: { title: 'Trip' }, blocks: [] },
});
expect(await render()).not.toContain(getServerDictionary().journal.draft);
});
});

View File

@@ -54,7 +54,7 @@
color: inherit;
background: var(--bg-card, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 12px;
border-radius: var(--radius-md);
overflow: hidden;
transition:
transform 0.25s ease,
@@ -157,7 +157,7 @@
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--accent, #6366f1);
color: var(--accent-text);
transition: gap 0.2s ease;
}
@@ -168,9 +168,9 @@
.journal-card__draft-badge {
display: inline-block;
padding: 2px 8px;
background: rgba(234, 179, 8, 0.15);
color: #eab308;
border-radius: 4px;
background: color-mix(in srgb, var(--warning) 15%, transparent);
color: var(--warning);
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;

View File

@@ -3,12 +3,13 @@ import Image from 'next/image';
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { type JournalEntrySummary } from '@/lib/journal';
import { formatJournalDate } from '@/lib/journalDate';
import { formatJournalDate, journalDateTime } from '@/lib/journalDate';
import { listJournalEntriesForRequest } from '@/lib/journal.server';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import { isAuthenticated } from '@/lib/auth';
import { immich } from '@/lib/immich';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { ogImageUrl } from '@/lib/ogImage';
import { BackLink } from '@/components/BackLink';
import { IconBook } from '@/components/Icons';
import { getServerDictionary } from '@/lib/i18n/server';
@@ -16,9 +17,22 @@ import './journal.css';
export const dynamic = 'force-dynamic';
/**
* Without an openGraph of its own the index inherited the layout's, so a
* shared /journal link previewed as the site's home page. The description is
* the subtitle the page shows, not a second sentence found nowhere on it.
*/
export function generateMetadata(): Metadata {
const t = getServerDictionary();
return { title: t.journal.title, description: t.journal.description };
const title = t.journal.title;
const description = t.journal.subtitle;
const ogImage = ogImageUrl(title);
return {
title,
description,
openGraph: { title, description, images: [ogImage] },
twitter: { card: 'summary_large_image', title, description, images: [ogImage] },
};
}
interface EnrichedJournalEntry extends JournalEntrySummary {
@@ -58,7 +72,7 @@ export default async function JournalIndexPage() {
const ph = asset ? assetPlaceholder(asset) : null;
return {
...entry,
coverUrl: imageUrl(entry.frontmatter.coverAssetId, 'preview'),
coverUrl: imageUrl(asset ?? entry.frontmatter.coverAssetId, 'preview'),
blurDataURL: ph?.blurDataURL,
dominantColor: ph?.dominantColor,
};
@@ -90,6 +104,7 @@ export default async function JournalIndexPage() {
<div className="journal-grid">
{enrichedEntries.map((entry) => {
const dateStr = formatJournalDate(entry.frontmatter.date, t.dateLocale);
const dateTime = journalDateTime(entry.frontmatter.date);
return (
<Link key={entry.slug} href={`/journal/${entry.slug}`} className="journal-card">
@@ -117,7 +132,12 @@ export default async function JournalIndexPage() {
<div className="journal-card__content">
<div className="journal-card__meta">
{dateStr && <span>{dateStr}</span>}
{dateStr &&
(dateTime ? (
<time dateTime={dateTime}>{dateStr}</time>
) : (
<span>{dateStr}</span>
))}
{dateStr && entry.readingTimeMinutes && <span>•</span>}
{entry.readingTimeMinutes && (
<span>{t.journal.minRead(entry.readingTimeMinutes)}</span>

View File

@@ -15,11 +15,12 @@ import { ScrollToTop } from '@/components/ScrollToTop';
import { Footer } from '@/components/Footer';
import { SetupScreen } from '@/components/SetupScreen';
import { getConfigOrNull, getThemeFontsUrl, AppConfig } from '@/lib/config';
import { accentForMode, onAccent } from '@/lib/config/theme';
import { accentForMode, accentText, onAccent } from '@/lib/config/theme';
import { isAdminPath } from '@/lib/admin/paths';
import { isInstallPath } from '@/lib/install';
import { isSiteLocked, isSiteUnlocked } from '@/lib/auth';
import { ogImageUrl } from '@/lib/ogImage';
import { THEME_INIT_SCRIPT } from '@/lib/themeScript';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
import AssetProtection from '@/components/AssetProtection';
@@ -81,7 +82,8 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const config = getConfigOrNull();
const pathname = (await headers()).get('x-pathname');
const requestHeaders = await headers();
const pathname = requestHeaders.get('x-pathname');
// gallery.yaml exists but cannot be derived — an empty gallery, a nameless
// subpage, a subpage with no albums. The admin page builder can write all
@@ -106,6 +108,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
'--accent-light': accentForMode(theme, 'light'),
'--on-accent-dark': onAccent(accentForMode(theme, 'dark')),
'--on-accent-light': onAccent(accentForMode(theme, 'light')),
'--accent-text-dark': accentText(accentForMode(theme, 'dark'), 'dark'),
'--accent-text-light': accentText(accentForMode(theme, 'light'), 'light'),
'--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`,
'--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
'--font-caption': `'${theme.fonts.caption}', Georgia, serif`,
@@ -140,6 +144,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
* (`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.
*
* The same holds for the pages the proxy serves to a locked site
* (`UNGATED_PAGES` in proxy.ts): /impressum, /privacy and /contact render
* bare here until the visitor has unlocked the site.
*/
const cookieStore = await cookies();
const siteGated =
@@ -158,11 +166,21 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/* 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). */
that script and ThemeToggle read for a visitor who has never chosen
(#512). */
{...(config.colorMode === 'auto' ? {} : { 'data-theme': config.colorMode })}
data-default-theme={config.colorMode}
>
<head>
{/* Applies a stored or OS-derived mode before the first paint, on
every page this layout renders — the gate and /admin included.
The nonce is the one proxy.ts put in the CSP; without it the
script is blocked silently. */}
<script
nonce={requestHeaders.get('x-nonce') ?? undefined}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
/>
<link rel="stylesheet" href={fontsUrl} />
</head>
<body>

View File

@@ -95,7 +95,7 @@
.map-popup__album-link {
font-size: 0.8125rem;
color: var(--accent, #e60012);
color: var(--accent-text);
text-decoration: none;
transition: opacity 0.15s;
}

View File

@@ -4,7 +4,9 @@
*/
import { getConfig } from '@/lib/config';
import { isListedSubpage, listedAlbumIds } from '@/lib/config/schema';
import { visibleMapCounts } from '@/lib/mapService';
import { isAuthenticated } from '@/lib/auth';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import { MapView } from '@/components/MapView';
import { BackLink } from '@/components/BackLink';
@@ -21,7 +23,7 @@ export function generateMetadata(): Metadata {
return { title: getServerDictionary().map.title };
}
export default function MapPage() {
export default async function MapPage() {
const config = getConfig();
const t = getServerDictionary();
@@ -29,10 +31,14 @@ export default function MapPage() {
notFound();
}
// Counts what the map may show: a hidden subpage is reachable by direct
// link only, so neither it nor an album only it carries is counted.
const collectionCount = config.subpages.filter(isListedSubpage).length;
const albumCount = listedAlbumIds(config).size;
// Counts what the map may show this viewer: a hidden subpage is reachable
// by direct link only, and a password-protected subpage or album the
// viewer has not unlocked is dropped by /api/map — neither is counted.
const cookieStore = await cookies();
const { collections: collectionCount, albums: albumCount } = visibleMapCounts(
config,
(key, type) => isAuthenticated(key, (name) => cookieStore.get(name)?.value, type),
);
return (
<>

View File

@@ -1,6 +1,21 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { getServerDictionary } from '@/lib/i18n/server';
/**
* The head of a 404 that was decided before streaming — by proxy.ts or by a
* page calling notFound() outside any Suspense boundary. Next then renders
* this file inside the root layout and takes the metadata from the layouts
* and from here, never from the page, so without this the tab read the bare
* site title while a soft 404 read "Not found".
*
* `robots: null` drops the layout's `index, follow`, which contradicted the
* `noindex` Next adds to every 404 on its own.
*/
export function generateMetadata(): Metadata {
return { title: getServerDictionary().error.notFoundTitle, robots: null };
}
/**
* Rendered when a page calls notFound() — i.e. Immich answered and the album
* or asset genuinely does not exist.

195
app/themes/birch.css Normal file
View File

@@ -0,0 +1,195 @@
/* ══════════════════════════════════════════════════════
THEME: Birch — Scandinavian daylight
══════════════════════════════════════════════════════
Birch-white and pine-night over green-tinted greys (tokens.css), pine
and lichen as the only accent, Albert Sans throughout. The signature is
what is missing: no uppercase and no letter-spacing anywhere in the
interface, where every other preset uses tracked caps. Corners come from
the theme radius (6px). */
/* ── Sentence case everywhere ─────────────────────── */
/* :is() takes its most specific argument, so one rule outranks every base
and hero-specific caps rule it lists. */
[data-preset='birch']
:is(
.header__nav-link,
.header__wordmark,
.hero__title,
.hero__subtitle,
.hero__nav-link,
.hero--stacked .hero__title,
.hero--stacked .hero__subtitle,
.hero--fullbleed .hero__subtitle,
.hero--mosaic .hero__title,
.hero--mosaic .hero__subtitle,
.hero--mosaic .hero__nav-link,
.hero__thumbnail-label,
.hero__thumbnail-index,
.hero__nav-index,
.section-label,
.album-header__title,
.album-header__meta,
.album-header__download,
.subpage-title,
.subpage-subtitle,
.subpage-section__title,
.subpage-section__desc,
.subpage-toc__num,
.subpage-toc__label,
.subpage-grid__item-title,
.subpage-grid__item-count,
.subpage-grid__item-badge,
.album-card__title,
.album-card__count,
.photo-grid__item-exif,
.footer__name,
.footer__legal-link,
.back-link
) {
text-transform: none;
letter-spacing: 0;
font-style: normal;
}
/* ── Header ───────────────────────────────────────── */
[data-preset='birch'] .header {
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
border-bottom-color: var(--border-subtle);
}
[data-preset='birch'] .header__nav-link {
font-size: 0.85rem;
font-weight: 500;
border-bottom-color: transparent;
}
[data-preset='birch'] .header__nav-link:hover {
border-bottom-color: transparent;
}
/* The active link sits in a soft pill rather than on an underline. */
[data-preset='birch'] .header__nav-link.active {
border-bottom-color: transparent;
background: var(--bg-card-hover);
border-radius: 999px;
}
/* ── Type ─────────────────────────────────────────── */
[data-preset='birch'] .hero__title,
[data-preset='birch'] .album-header__title,
[data-preset='birch'] .subpage-title {
font-weight: 500;
}
[data-preset='birch'] .section-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
}
/* ── Photo grid: soft corners, pill EXIF ──────────── */
[data-preset='birch'] .photo-grid {
--grid-gap: 10px;
}
[data-preset='birch'] .photo-grid__item img {
transition: filter 300ms ease;
}
[data-preset='birch'] .photo-grid__item:hover img {
transform: none;
filter: brightness(1.03);
}
[data-preset='birch'] .photo-grid__item-exif {
left: 10px;
right: auto;
bottom: 10px;
max-width: calc(100% - 20px);
padding: 4px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-card) 88%, transparent);
color: var(--text-primary);
font-size: 0.7rem;
font-weight: 500;
transition-duration: 300ms;
}
/* ── Covers: soft corners, a 2px lift ─────────────── */
[data-preset='birch'] .subpage-grid {
--subpage-gap: 16px;
}
[data-preset='birch'] .subpage-grid__item,
[data-preset='birch'] .album-card {
border-radius: var(--radius-sm);
transition: transform 300ms ease;
}
[data-preset='birch'] .subpage-grid__item:hover,
[data-preset='birch'] .album-card:hover {
transform: translateY(-2px);
}
[data-preset='birch'] .subpage-grid__item:hover img {
transform: none;
filter: brightness(0.8);
}
[data-preset='birch'] .subpage-grid__item-title,
[data-preset='birch'] .album-card__title {
font-weight: 500;
}
/* ── Stacked hero: the photo inset with soft corners ─ */
[data-preset='birch'] .hero--stacked {
gap: 12px;
padding: 16px 24px 24px;
}
[data-preset='birch'] .hero__stacked-image {
border-radius: var(--radius-md);
overflow: hidden;
}
[data-preset='birch'] .hero--stacked .hero__title {
font-family: var(--font-serif);
font-weight: 500;
}
[data-preset='birch'] .hero--stacked .hero__subtitle {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.85);
}
[data-preset='birch'] .hero__thumbnail-strip {
gap: 8px;
background: none;
}
[data-preset='birch'] .hero__thumbnail-item {
padding: 10px 18px;
border-radius: 999px;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
}
[data-preset='birch'] .hero__thumbnail-item:hover {
background: var(--bg-card-hover);
}
[data-preset='birch'] .hero__thumbnail-label {
font-size: 0.85rem;
font-weight: 500;
}
@media (max-width: 640px) {
[data-preset='birch'] .hero--stacked {
padding: 8px 12px 16px;
}
[data-preset='birch'] .hero__stacked-overlay {
padding: 32px 20px;
}
}

View File

@@ -51,7 +51,7 @@
display: block;
margin-top: 20px;
font-size: 0.5rem;
color: var(--accent);
color: var(--accent-text);
letter-spacing: 0;
}
@@ -66,7 +66,7 @@
}
[data-preset='classic'] .hero__nav-link:hover {
color: var(--accent);
color: var(--accent-text);
border-bottom-color: transparent;
}
@@ -84,17 +84,6 @@
transform: scale(1.01);
}
/* Warm passepartout override */
[data-preset='classic'][data-photo-frame='passepartout'] .photo-grid__item {
background: #1a1816;
border-color: rgba(196, 154, 60, 0.12);
}
[data-preset='classic'][data-photo-frame='passepartout'][data-theme='light'] .photo-grid__item {
background: #fffdf5;
border-color: rgba(196, 154, 60, 0.2);
}
/* Album cards: warm rounded, gold hover */
[data-preset='classic'] .album-card {
border-radius: 12px;
@@ -135,12 +124,12 @@
[data-preset='classic'] .section-label::before {
content: '— ';
color: var(--accent);
color: var(--accent-text);
}
[data-preset='classic'] .section-label::after {
content: ' —';
color: var(--accent);
color: var(--accent-text);
}
/* Album & Subpage header: elegant serif */
@@ -177,7 +166,7 @@
[data-preset='classic'] .scroll-to-top:hover {
border-color: var(--accent);
color: var(--accent);
color: var(--accent-text);
}
/* Subpage grid: warm borders */
@@ -202,7 +191,7 @@
content: '◆';
padding: 0 14px;
font-size: 0.45rem;
color: var(--accent);
color: var(--accent-text);
}
[data-preset='classic'] .subpage-toc__num {
@@ -222,7 +211,7 @@
}
[data-preset='classic'] .subpage-toc__link:hover {
color: var(--accent);
color: var(--accent-text);
}
/* Section: ornamental centered divider rule */
@@ -259,5 +248,5 @@
content: '—◆—';
font-size: 0.55rem;
letter-spacing: 0.2em;
color: var(--accent);
color: var(--accent-text);
}

278
app/themes/cyanotype.css Normal file
View File

@@ -0,0 +1,278 @@
/* ══════════════════════════════════════════════════════
THEME: Cyanotype — a Prussian-blue print, set like a drawing sheet
══════════════════════════════════════════════════════
Ink-blue night in dark mode; in light mode every piece of text is
Prussian ink on cool paper (tokens.css). Jost for type, DM Mono 300 for
numbers and EXIF, ruled title blocks, registration marks, and sheets
indexed A-01, A-02. */
[data-preset='cyanotype'] {
/* Registration marks sit on the photo, so they are light in both modes. */
--cy-reg: rgba(255, 255, 255, 0.75);
}
/* ── Header: one ruled line ───────────────────────── */
[data-preset='cyanotype'] .header {
background: color-mix(in srgb, var(--bg-primary) 94%, transparent);
border-bottom: 1px solid var(--border-hover);
}
[data-preset='cyanotype'] .header__nav-link {
font-size: 0.68rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
border-bottom-color: transparent;
}
[data-preset='cyanotype'] .header__nav-link:hover {
border-bottom-color: transparent;
}
[data-preset='cyanotype'] .header__nav-link.active {
color: var(--accent-text, var(--accent));
border-bottom-color: transparent;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 6px;
}
/* ── Type ─────────────────────────────────────────── */
[data-preset='cyanotype'] .hero__title,
[data-preset='cyanotype'] .album-header__title,
[data-preset='cyanotype'] .subpage-title {
font-weight: 300;
letter-spacing: -0.01em;
text-transform: none;
}
[data-preset='cyanotype'] .hero__subtitle,
[data-preset='cyanotype'] .subpage-subtitle {
font-style: normal;
}
[data-preset='cyanotype'] .section-label,
[data-preset='cyanotype'] .subpage-section__desc {
font-size: 0.68rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* Title block: the album metadata as a ruled cell, in DM Mono. */
[data-preset='cyanotype'] .album-header__meta {
display: inline-flex;
flex-wrap: wrap;
border: 1px solid var(--border-hover);
font-family: var(--font-caption);
font-size: 0.7rem;
font-weight: 300;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary);
}
[data-preset='cyanotype'] .album-header__meta > span {
padding: 6px 12px;
}
[data-preset='cyanotype'] .album-header__meta > span + span {
border-left: 1px solid var(--border-hover);
}
/* ── Photo grid: the architectural module ─────────── */
[data-preset='cyanotype'] .photo-grid {
--grid-gap: 24px;
}
[data-preset='cyanotype'] .photo-grid__item:hover img {
transform: none;
filter: none;
}
/* Registration marks: four 12px corner ticks, faded in on hover and focus.
::after belongs to the grain overlay, so they use ::before. */
[data-preset='cyanotype'] .photo-grid__item::before {
content: '';
position: absolute;
inset: 8px;
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity var(--transition-fast);
background:
linear-gradient(var(--cy-reg), var(--cy-reg)) left top / 12px 1px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) left top / 1px 12px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) right top / 12px 1px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) right top / 1px 12px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) left bottom / 12px 1px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) left bottom / 1px 12px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) right bottom / 12px 1px no-repeat,
linear-gradient(var(--cy-reg), var(--cy-reg)) right bottom / 1px 12px no-repeat;
}
[data-preset='cyanotype'] .photo-grid__item:hover::before,
[data-preset='cyanotype'] .photo-grid__item:focus-visible::before {
opacity: 1;
}
[data-preset='cyanotype'] .photo-grid__item-exif {
font-family: var(--font-caption);
font-size: 0.65rem;
font-weight: 300;
letter-spacing: 0.04em;
text-transform: uppercase;
transition-duration: 200ms;
}
/* ── Covers: sheets with a caption bar ────────────── */
[data-preset='cyanotype'] .subpage-grid {
--subpage-gap: 24px;
}
[data-preset='cyanotype'] .subpage-grid__item {
aspect-ratio: auto;
border: 1px solid var(--border-subtle);
background: var(--bg-card);
transition: border-color var(--transition-fast);
}
[data-preset='cyanotype'] .subpage-grid__item:hover {
border-color: var(--border-hover);
}
[data-preset='cyanotype'] .subpage-grid__item-media {
aspect-ratio: 3 / 2;
height: auto;
}
[data-preset='cyanotype'] .subpage-grid__item:hover img {
transform: none;
filter: none;
}
[data-preset='cyanotype'] .subpage-grid__item-overlay,
[data-preset='cyanotype'] .subpage-grid__item-badge {
display: none;
}
[data-preset='cyanotype'] .subpage-grid__item-caption {
display: flex;
align-items: baseline;
gap: 14px;
padding: 12px 16px;
border-top: 1px solid var(--border-subtle);
}
[data-preset='cyanotype'] .subpage-grid__item-index,
[data-preset='cyanotype'] .subpage-grid__item-caption-count {
font-family: var(--font-caption);
font-size: 0.68rem;
font-weight: 400;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
[data-preset='cyanotype'] .subpage-grid__item-index {
color: var(--accent-text, var(--accent));
}
[data-preset='cyanotype'] .subpage-grid__item-index::before,
[data-preset='cyanotype'] .hero__nav-index::before {
content: 'A-';
}
[data-preset='cyanotype'] .subpage-grid__item-caption-title {
font-size: 0.95rem;
font-weight: 400;
color: var(--text-primary);
}
[data-preset='cyanotype'] .subpage-grid__item-caption-count {
margin-left: auto;
color: var(--text-muted);
}
[data-preset='cyanotype'] .album-card {
border: 1px solid var(--border-subtle);
}
[data-preset='cyanotype'] .album-card:hover {
transform: none;
border-color: var(--border-hover);
}
/* ── Split hero: sheet list and title block ───────── */
[data-preset='cyanotype'] .hero--split .hero__content {
text-align: left;
}
[data-preset='cyanotype'] .hero--split .hero__nav {
align-items: stretch;
gap: 0;
border-top: 1px solid var(--border-hover);
}
[data-preset='cyanotype'] .hero--split .hero__nav-link {
display: flex;
align-items: baseline;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--border-subtle);
font-weight: 400;
text-decoration: none;
}
[data-preset='cyanotype'] .hero--split .hero__nav-link:hover {
color: var(--accent-text, var(--accent));
}
[data-preset='cyanotype'] .hero .hero__nav-index {
display: inline-block;
font-family: var(--font-caption);
font-size: 0.68rem;
font-weight: 300;
color: var(--accent-text, var(--accent));
}
/* The hero EXIF chip, drawn as a cell of a drawing's title block. */
[data-preset='cyanotype'] .hero--split .hero__exif-chip {
position: absolute;
left: 24px;
bottom: 24px;
z-index: 3;
display: inline-flex;
max-width: calc(100% - 96px);
padding: 6px 10px;
border: 1px solid var(--border-hover);
background: var(--bg-primary);
color: var(--text-secondary);
font-family: var(--font-caption);
font-size: 0.68rem;
font-weight: 300;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
[data-preset='cyanotype'] .footer {
border-top: 1px solid var(--border-hover);
}
@media (max-width: 640px) {
[data-preset='cyanotype'] .photo-grid {
--grid-gap: 12px;
}
[data-preset='cyanotype'] .hero--split .hero__exif-chip {
left: 12px;
bottom: 12px;
max-width: calc(100% - 80px);
}
}

View File

@@ -47,7 +47,7 @@
}
[data-preset='editorial'] .hero__nav-link:hover {
color: var(--accent);
color: var(--accent-text);
border-bottom-color: transparent;
}
@@ -213,7 +213,7 @@
}
[data-preset='editorial'] .subpage-toc__link:hover {
color: var(--accent);
color: var(--accent-text);
}
/* Section: pull-quote accent border, large cinematic title */

Some files were not shown because too many files have changed in this diff Show More