release: v0.20.0
This commit is contained in:
@@ -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() {
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
@@ -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))',
|
||||
|
||||
28
app/[...path]/PathSkeleton.tsx
Normal file
28
app/[...path]/PathSkeleton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
181
app/[...path]/__tests__/not-found-status.test.ts
Normal file
181
app/[...path]/__tests__/not-found-status.test.ts
Normal 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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
73
app/[...path]/__tests__/photo-deeplink-invalid.test.tsx
Normal file
73
app/[...path]/__tests__/photo-deeplink-invalid.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
126
app/[...path]/__tests__/photo-wall-label.test.tsx
Normal file
126
app/[...path]/__tests__/photo-wall-label.test.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
65
app/[...path]/__tests__/photoItems-edited.test.ts
Normal file
65
app/[...path]/__tests__/photoItems-edited.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
123
app/[...path]/__tests__/subpage-metadata.test.ts
Normal file
123
app/[...path]/__tests__/subpage-metadata.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
70
app/[...path]/routeExists.ts
Normal file
70
app/[...path]/routeExists.ts
Normal 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;
|
||||
}
|
||||
72
app/__tests__/gate-page.test.tsx
Normal file
72
app/__tests__/gate-page.test.tsx
Normal 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>');
|
||||
});
|
||||
});
|
||||
29
app/__tests__/not-found-metadata.test.ts
Normal file
29
app/__tests__/not-found-metadata.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
68
app/about/__tests__/about-meta.test.ts
Normal file
68
app/about/__tests__/about-meta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
61
app/admin/__tests__/SortableTiles.test.tsx
Normal file
61
app/admin/__tests__/SortableTiles.test.tsx
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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' } }]);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
35
app/admin/__tests__/sameDraft.test.ts
Normal file
35
app/admin/__tests__/sameDraft.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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);
|
||||
|
||||
67
app/admin/components/__tests__/zoom-settings.test.tsx
Normal file
67
app/admin/components/__tests__/zoom-settings.test.tsx
Normal 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]]);
|
||||
});
|
||||
});
|
||||
28
app/admin/components/dndAnnouncements.ts
Normal file
28
app/admin/components/dndAnnouncements.ts
Normal 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);
|
||||
}
|
||||
46
app/admin/components/fields/ZoomOverrideField.tsx
Normal file
46
app/admin/components/fields/ZoomOverrideField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
app/admin/components/sameDraft.ts
Normal file
31
app/admin/components/sameDraft.ts
Normal 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));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
102
app/api/admin/__tests__/doctor-zoom.test.ts
Normal file
102
app/api/admin/__tests__/doctor-zoom.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '-');
|
||||
|
||||
@@ -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) } },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
185
app/api/download/[album]/[id]/__tests__/route.test.ts
Normal file
185
app/api/download/[album]/[id]/__tests__/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]) : [];
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
279
app/api/zoom/[album]/[id]/__tests__/route.test.ts
Normal file
279
app/api/zoom/[album]/[id]/__tests__/route.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
181
app/api/zoom/[album]/[id]/route.ts
Normal file
181
app/api/zoom/[album]/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
179
app/globals.css
179
app/globals.css
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
94
app/journal/__tests__/journal-meta-draft.test.tsx
Normal file
94
app/journal/__tests__/journal-meta-draft.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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
195
app/themes/birch.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
278
app/themes/cyanotype.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user