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