release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
@@ -1,35 +1,16 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
import { readJournalEntry, listJournalEntries } from '@/lib/admin/journal-service';
|
||||
import {
|
||||
collectAssetIds,
|
||||
mapBlockAssetIds,
|
||||
type JournalBlock,
|
||||
type MapPin,
|
||||
type ParsedJournal,
|
||||
} from '@/lib/journal';
|
||||
import { entryMapPins } from '@/lib/journalMap';
|
||||
import { expandAlbumBlocks } from '@/lib/journalAlbum';
|
||||
import { readJournalEntryForRequest, listJournalEntriesForRequest } from '@/lib/journal.server';
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import { buildEssayPayload } from '@/app/[...path]/essayPayload';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
import { journalNeighbours } from '@/lib/journalNav';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import {
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
exifUrl,
|
||||
assetPlaceholder,
|
||||
assetAspectRatio,
|
||||
assetCaption,
|
||||
assetExifSummary,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { imageUrl } from '@/lib/urls';
|
||||
import { EssayView } from '@/app/[...path]/EssayView';
|
||||
import { JournalNav } from '@/components/JournalNav';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import type { ImmichAsset } from '@/lib/immich';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
@@ -43,9 +24,12 @@ interface JournalDetailPageProps {
|
||||
|
||||
export async function generateMetadata({ params }: JournalDetailPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const entry = await readJournalEntry(slug);
|
||||
const entry = await readJournalEntryForRequest(slug);
|
||||
const t = getServerDictionary();
|
||||
if (!entry) return { title: t.journal.notFound };
|
||||
// Nothing to describe: the requested slug is not echoed, and `robots: null`
|
||||
// drops the layout's `index, follow`, which would contradict the noindex
|
||||
// Next adds to the 404 below.
|
||||
if (!entry) return { title: t.journal.notFound, robots: null };
|
||||
|
||||
const { frontmatter } = entry.parsed;
|
||||
|
||||
@@ -54,14 +38,15 @@ export async function generateMetadata({ params }: JournalDetailPageProps): Prom
|
||||
// entry's real title, subtitle and cover image reached the <head> of a
|
||||
// 200 response no authentication was ever asked for (GHSA-fvgv-97g3-wjr7).
|
||||
const isAuthedAdmin = await isAdminAuthenticated();
|
||||
// A draft is a 404 for everyone but an admin; describe it as one.
|
||||
if (frontmatter.draft && !isAuthedAdmin) return { title: t.journal.notFound, robots: null };
|
||||
const blocked =
|
||||
(frontmatter.draft && !isAuthedAdmin) ||
|
||||
(!!frontmatter.password &&
|
||||
!isJournalAuthenticated(
|
||||
slug,
|
||||
frontmatter.password,
|
||||
(await cookies()).get(`lb_auth_journal_${slug}`)?.value,
|
||||
));
|
||||
!!frontmatter.password &&
|
||||
!isJournalAuthenticated(
|
||||
slug,
|
||||
frontmatter.password,
|
||||
(await cookies()).get(`lb_auth_journal_${slug}`)?.value,
|
||||
);
|
||||
|
||||
const title = blocked ? t.journal.title : frontmatter.title || slug;
|
||||
const description = blocked
|
||||
@@ -117,13 +102,13 @@ function isJournalAuthenticated(
|
||||
|
||||
export default async function JournalDetailPage({ params }: JournalDetailPageProps) {
|
||||
const { slug } = await params;
|
||||
const entry = await readJournalEntry(slug);
|
||||
const entry = await readJournalEntryForRequest(slug);
|
||||
|
||||
if (!entry) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { frontmatter, blocks: authoredBlocks, referencedAssetIds } = entry.parsed;
|
||||
const { frontmatter, blocks: authoredBlocks } = entry.parsed;
|
||||
const isAuthedAdmin = await isAdminAuthenticated();
|
||||
|
||||
// If draft, only visible to authenticated admin
|
||||
@@ -137,7 +122,12 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
const cookieVal = cookieStore.get(`lb_auth_journal_${slug}`)?.value;
|
||||
|
||||
if (!isJournalAuthenticated(slug, frontmatter.password, cookieVal)) {
|
||||
return <PasswordGate slug={slug} title={frontmatter.title || slug} type="journal" />;
|
||||
// The generic journal title, like generateMetadata above: the index and
|
||||
// the prev/next links hide a locked entry by name, so its gate must not
|
||||
// print that name either (GHSA-fvgv-97g3-wjr7).
|
||||
return (
|
||||
<PasswordGate slug={slug} title={getServerDictionary().journal.title} type="journal" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +138,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
// here even by name, the same rule the index and this entry's own gate
|
||||
// already enforce (GHSA-fvgv-97g3-wjr7).
|
||||
const journalCookies = await cookies();
|
||||
const allEntries = await listJournalEntries();
|
||||
const allEntries = await listJournalEntriesForRequest();
|
||||
const visibleEntries = isAuthedAdmin
|
||||
? allEntries
|
||||
: allEntries.filter((e) => {
|
||||
@@ -162,98 +152,15 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
);
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Fetch all referenced assets from Immich
|
||||
const assetPromises = referencedAssetIds.map(async (assetId) => {
|
||||
try {
|
||||
const asset = await immich.getAssetInfo(assetId);
|
||||
return asset;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const { essay, images, coverToken } = await buildEssayPayload(authoredBlocks, {
|
||||
logTag: 'journal',
|
||||
slug,
|
||||
allowMap: true,
|
||||
coverAssetId: frontmatter.coverAssetId,
|
||||
});
|
||||
|
||||
const fetchedAssets = (await Promise.all(assetPromises)).filter(
|
||||
(a): a is ImmichAsset => a !== null,
|
||||
);
|
||||
|
||||
// Album blocks become photo blocks here. The raw fetch bypasses the album
|
||||
// allowlist on purpose: an entry may already show any single photo by id,
|
||||
// and the author's pick is the gate for a whole album just the same. An
|
||||
// album Immich cannot deliver drops its block rather than the page.
|
||||
const albumAssetsById = new Map<string, ImmichAsset[]>();
|
||||
for (const block of authoredBlocks) {
|
||||
if (block.type !== 'album' || !block.albumId || albumAssetsById.has(block.albumId)) continue;
|
||||
try {
|
||||
albumAssetsById.set(block.albumId, await immich.getAlbumAssetsRaw(block.albumId));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[journal] ${slug}: album ${block.albumId} could not be loaded:`, error);
|
||||
}
|
||||
}
|
||||
const { blocks } = expandAlbumBlocks(
|
||||
authoredBlocks,
|
||||
(albumId) => albumAssetsById.get(albumId),
|
||||
config.albumManualOrders,
|
||||
);
|
||||
const knownIds = new Set(fetchedAssets.map((a) => a.id));
|
||||
const rawAssets = [...fetchedAssets];
|
||||
for (const list of albumAssetsById.values()) {
|
||||
for (const asset of list) {
|
||||
if (knownIds.has(asset.id)) continue;
|
||||
knownIds.add(asset.id);
|
||||
rawAssets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
// EssayView drops photo blocks it cannot resolve, which is right for visitors
|
||||
// but leaves no trace of *why* a photo vanished. Legacy positional references
|
||||
// ("1", "2") are the usual cause: they only resolved against a subpage album,
|
||||
// and a standalone journal entry has none.
|
||||
if (fetchedAssets.length < referencedAssetIds.length) {
|
||||
const resolved = new Set(fetchedAssets.map((a) => a.id));
|
||||
const missing = referencedAssetIds.filter((id) => !resolved.has(id));
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[journal] ${slug}: ${missing.length} photo reference(s) could not be resolved and will not render: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* EssayView resolves a block's photo through PhotoItem.id, and those ids are
|
||||
* encrypted tokens — while the blocks coming out of the Markdown carry raw
|
||||
* asset UUIDs. Without this translation every photo block fails its lookup and
|
||||
* is dropped, so a correctly authored entry renders text only. Doing it here
|
||||
* also keeps raw UUIDs out of the client payload, per the project rule that
|
||||
* they must never reach the browser. Unresolvable references collapse to an
|
||||
* empty string, which EssayView skips.
|
||||
*/
|
||||
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
|
||||
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
|
||||
|
||||
// A map block's pins are computed here, from the photos fetched above and
|
||||
// under each photo's `location:` precision, and only when the site
|
||||
// publishes a map at all. With the map switched off the block is dropped
|
||||
// before it can reach the client; with it on, the client gets pins only —
|
||||
// never the items, which carry raw asset ids.
|
||||
const entryPhotoIds = collectAssetIds(blocks.filter((b) => b.type !== 'map'));
|
||||
const pinsByBlock = new Map<JournalBlock, MapPin[]>();
|
||||
if (config.map) {
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'map') {
|
||||
pinsByBlock.set(block, await entryMapPins(block.items, rawAssets, entryPhotoIds));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Photos that only anchor a map pin are fetched for their EXIF but are not
|
||||
// part of the story, so they stay out of the lightbox sequence.
|
||||
const shownAssetIds = new Set([
|
||||
...entryPhotoIds,
|
||||
...(frontmatter.coverAssetId ? [frontmatter.coverAssetId] : []),
|
||||
]);
|
||||
|
||||
const essayForClient: ParsedJournal = {
|
||||
...essay,
|
||||
// Named fields, not a spread of `frontmatter`: EssayView reads only
|
||||
// title, subtitle, coverAssetId, author and date, but a spread put the
|
||||
// stored password — plaintext or a scrypt hash — into every visitor's
|
||||
@@ -263,44 +170,10 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
subtitle: frontmatter.subtitle,
|
||||
author: frontmatter.author,
|
||||
date: frontmatter.date,
|
||||
coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined,
|
||||
coverAssetId: coverToken,
|
||||
},
|
||||
blocks: blocks.flatMap((block): JournalBlock[] => {
|
||||
if (block.type === 'map') {
|
||||
const pins = pinsByBlock.get(block);
|
||||
return pins
|
||||
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
|
||||
: [];
|
||||
}
|
||||
return [mapBlockAssetIds(block, toToken)];
|
||||
}),
|
||||
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
|
||||
};
|
||||
|
||||
const images: PhotoItem[] = rawAssets
|
||||
.filter((a) => shownAssetIds.has(a.id) && (a.type === 'IMAGE' || a.type === 'VIDEO'))
|
||||
.map((a) => {
|
||||
const ph = assetPlaceholder(a);
|
||||
const exif =
|
||||
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
|
||||
? assetExifSummary(a)
|
||||
: undefined;
|
||||
const caption = assetCaption(a, config.exif.caption);
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
id: encodeAssetId(a.id),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
thumbUrl: imageUrl(a.id, 'preview'),
|
||||
previewUrl: imageUrl(a.id, 'preview'),
|
||||
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
|
||||
exifUrl: exifUrl(a.id),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||
...(exif ?? {}),
|
||||
...(caption ? { caption } : {}),
|
||||
aspectRatio: assetAspectRatio(a),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ paddingTop: '2rem' }}>
|
||||
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '0 1.5rem 1rem' }}>
|
||||
|
||||
96
app/journal/__tests__/locked-gate-title.test.ts
Normal file
96
app/journal/__tests__/locked-gate-title.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
* A password-protected journal entry is hidden by name everywhere a visitor
|
||||
* can look before unlocking it: the index, the prev/next links and the page
|
||||
* metadata (GHSA-fvgv-97g3-wjr7). The gate itself printed the real title,
|
||||
* which undid all of that for anyone who guessed or was sent the URL.
|
||||
*/
|
||||
|
||||
const readJournalEntry = vi.fn();
|
||||
|
||||
vi.mock('@/lib/admin/journal-service', () => ({
|
||||
readJournalEntry: (slug: string) => readJournalEntry(slug),
|
||||
listJournalEntries: async () => [],
|
||||
}));
|
||||
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||
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' }),
|
||||
getConfigOrNull: () => ({ lang: 'en' }),
|
||||
}));
|
||||
vi.mock('@/app/[...path]/essayPayload', () => ({ buildEssayPayload: vi.fn() }));
|
||||
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 { default: PasswordGate } = await import('@/components/PasswordGate');
|
||||
const { getServerDictionary } = await import('@/lib/i18n/server');
|
||||
|
||||
describe('journal entry password gate', () => {
|
||||
beforeEach(() => {
|
||||
readJournalEntry.mockReset();
|
||||
readJournalEntry.mockResolvedValue({
|
||||
slug: 'secret-trip',
|
||||
parsed: {
|
||||
frontmatter: { title: 'Weekend with Alex in Lisbon', password: 'hunter2' },
|
||||
blocks: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the generic journal title, not the entry title', async () => {
|
||||
const el = (await JournalDetailPage({
|
||||
params: Promise.resolve({ slug: 'secret-trip' }),
|
||||
})) as ReactElement<{ title: string; slug: string; type: string }>;
|
||||
|
||||
expect(el.type).toBe(PasswordGate);
|
||||
expect(el.props.type).toBe('journal');
|
||||
expect(el.props.slug).toBe('secret-trip');
|
||||
expect(el.props.title).toBe(getServerDictionary().journal.title);
|
||||
expect(el.props.title).not.toContain('Lisbon');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A missing entry and a draft both answer 404. Their metadata used to keep the
|
||||
* root layout's `index, follow` next to the noindex Next adds to a 404, and a
|
||||
* draft was titled like a live entry.
|
||||
*/
|
||||
describe('journal entry metadata on a 404', () => {
|
||||
const meta = async (slug: string) => {
|
||||
const { generateMetadata } = await import('../[slug]/page');
|
||||
return generateMetadata({ params: Promise.resolve({ slug }) });
|
||||
};
|
||||
|
||||
it('describes a missing entry as not found, with no robots tag of its own', async () => {
|
||||
readJournalEntry.mockResolvedValue(null);
|
||||
const m = await meta('no-such-entry');
|
||||
|
||||
expect(m.title).toBe(getServerDictionary().journal.notFound);
|
||||
expect(m.robots).toBeNull();
|
||||
expect(JSON.stringify(m)).not.toContain('no-such-entry');
|
||||
});
|
||||
|
||||
it('describes a draft as not found, not by its title', async () => {
|
||||
readJournalEntry.mockResolvedValue({
|
||||
slug: 'draft-trip',
|
||||
parsed: { frontmatter: { title: 'Unpublished Draft', draft: true }, blocks: [] },
|
||||
});
|
||||
const m = await meta('draft-trip');
|
||||
|
||||
expect(m.title).toBe(getServerDictionary().journal.notFound);
|
||||
expect(m.robots).toBeNull();
|
||||
expect(JSON.stringify(m)).not.toContain('Unpublished');
|
||||
});
|
||||
});
|
||||
141
app/journal/__tests__/request-memo.test.ts
Normal file
141
app/journal/__tests__/request-memo.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* One render of a journal page used to read the journal directory twice —
|
||||
* the header nav in the root layout listed every entry, then the page listed
|
||||
* them again — and an entry's file twice, once for `generateMetadata()` and
|
||||
* once for the page body. The home page loaded the whole site nav twice, for
|
||||
* the header and for the hero. Those reads now go through React's per-request
|
||||
* `cache()`.
|
||||
*
|
||||
* Outside a server render `cache()` does not memoise, so the test stands in a
|
||||
* memo that lasts until `newRequest()`: what it checks is that every read goes
|
||||
* through `cache()`, and that a new request reads the files again.
|
||||
*/
|
||||
|
||||
const memo = vi.hoisted(() => ({ current: new Map<unknown, Map<string, unknown>>() }));
|
||||
const newRequest = () => memo.current.clear();
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const react = await importOriginal<typeof import('react')>();
|
||||
return {
|
||||
...react,
|
||||
cache:
|
||||
<A extends unknown[], R>(fn: (...args: A) => R) =>
|
||||
(...args: A): R => {
|
||||
let results = memo.current.get(fn);
|
||||
if (!results) memo.current.set(fn, (results = new Map()));
|
||||
const key = JSON.stringify(args);
|
||||
if (!results.has(key)) results.set(key, fn(...args));
|
||||
return results.get(key) as R;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const calls = vi.hoisted(() => ({ list: 0, read: 0, pages: 0, subpages: 0 }));
|
||||
|
||||
const ENTRIES = ['gamma', 'beta', 'alpha'].map((slug) => ({
|
||||
slug,
|
||||
filename: `${slug}.md`,
|
||||
frontmatter: { title: slug.toUpperCase(), date: '2026-01-01' },
|
||||
excerpt: '',
|
||||
wordCount: 1,
|
||||
readingTimeMinutes: 1,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/journal-service', () => ({
|
||||
listJournalEntries: async () => {
|
||||
calls.list++;
|
||||
return ENTRIES;
|
||||
},
|
||||
readJournalEntry: async (slug: string) => {
|
||||
calls.read++;
|
||||
return {
|
||||
slug,
|
||||
rawMarkdown: '',
|
||||
parsed: { frontmatter: { title: slug.toUpperCase() }, blocks: [] },
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/pages-service', () => ({
|
||||
listPages: async () => {
|
||||
calls.pages++;
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: {
|
||||
getSubpages: async () => {
|
||||
calls.subpages++;
|
||||
return [];
|
||||
},
|
||||
getStandaloneAlbums: async () => [],
|
||||
getAssetInfo: async () => null,
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
authSecret: 'x'.repeat(32),
|
||||
nav: [],
|
||||
aboutEnabled: false,
|
||||
map: false,
|
||||
watermark: undefined,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/lib/i18n/server', async () => {
|
||||
const { getDictionary } = await import('@/lib/i18n');
|
||||
return { getServerDictionary: () => getDictionary('en') };
|
||||
});
|
||||
vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
|
||||
vi.mock('next/navigation', () => ({
|
||||
notFound: () => {
|
||||
throw new Error('notFound');
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||
vi.mock('@/app/[...path]/essayPayload', () => ({
|
||||
buildEssayPayload: async () => ({ essay: { frontmatter: {}, blocks: [] }, images: [] }),
|
||||
}));
|
||||
// Client components the page only hands props to.
|
||||
vi.mock('@/app/[...path]/EssayView', () => ({ EssayView: () => null }));
|
||||
vi.mock('@/components/JournalNav', () => ({ JournalNav: () => null }));
|
||||
vi.mock('@/components/PasswordGate', () => ({ default: () => null }));
|
||||
vi.mock('@/components/BackLink', () => ({ BackLink: () => null }));
|
||||
vi.mock('next/link', () => ({ default: () => null }));
|
||||
vi.mock('next/image', () => ({ default: () => null }));
|
||||
|
||||
import JournalEntryPage, { generateMetadata } from '../[slug]/page';
|
||||
import JournalIndexPage from '../page';
|
||||
import { loadSiteNav } from '@/lib/siteNav.server';
|
||||
|
||||
const params = { params: Promise.resolve({ slug: 'beta' }) };
|
||||
|
||||
beforeEach(() => {
|
||||
newRequest();
|
||||
Object.assign(calls, { list: 0, read: 0, pages: 0, subpages: 0 });
|
||||
});
|
||||
|
||||
describe('journal reads per request', () => {
|
||||
it('an entry page lists the journal once and reads its file once', async () => {
|
||||
// The order a render takes: layout nav, metadata, page body.
|
||||
await Promise.all([loadSiteNav(), generateMetadata(params), JournalEntryPage(params)]);
|
||||
expect(calls).toMatchObject({ list: 1, read: 1 });
|
||||
});
|
||||
|
||||
it('the journal index lists the journal once', async () => {
|
||||
await Promise.all([loadSiteNav(), JournalIndexPage()]);
|
||||
expect(calls.list).toBe(1);
|
||||
});
|
||||
|
||||
it('the home page loads the site nav once for the header and the hero', async () => {
|
||||
await Promise.all([loadSiteNav(), loadSiteNav()]);
|
||||
expect(calls).toMatchObject({ list: 1, pages: 1, subpages: 1 });
|
||||
});
|
||||
|
||||
it('reads the files again on the next request', async () => {
|
||||
await Promise.all([loadSiteNav(), generateMetadata(params), JournalEntryPage(params)]);
|
||||
newRequest();
|
||||
await Promise.all([loadSiteNav(), generateMetadata(params), JournalEntryPage(params)]);
|
||||
expect(calls).toMatchObject({ list: 2, read: 2 });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import { type JournalEntrySummary } from '@/lib/journal';
|
||||
import { listJournalEntries } from '@/lib/admin/journal-service';
|
||||
import { formatJournalDate } 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';
|
||||
@@ -29,7 +30,7 @@ interface EnrichedJournalEntry extends JournalEntrySummary {
|
||||
export default async function JournalIndexPage() {
|
||||
const t = getServerDictionary();
|
||||
const isAuthedAdmin = await isAdminAuthenticated();
|
||||
const allEntries = await listJournalEntries();
|
||||
const allEntries = await listJournalEntriesForRequest();
|
||||
|
||||
// Non-admins only see published entries, and only a password-protected one
|
||||
// once they have actually unlocked it — the card otherwise names it, shows
|
||||
@@ -88,13 +89,7 @@ export default async function JournalIndexPage() {
|
||||
) : (
|
||||
<div className="journal-grid">
|
||||
{enrichedEntries.map((entry) => {
|
||||
const dateStr = entry.frontmatter.date
|
||||
? new Date(entry.frontmatter.date).toLocaleDateString(t.dateLocale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
: null;
|
||||
const dateStr = formatJournalDate(entry.frontmatter.date, t.dateLocale);
|
||||
|
||||
return (
|
||||
<Link key={entry.slug} href={`/journal/${entry.slug}`} className="journal-card">
|
||||
|
||||
Reference in New Issue
Block a user