release: v0.20.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user