181 lines
6.8 KiB
TypeScript
181 lines
6.8 KiB
TypeScript
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
import type { Metadata } from 'next';
|
|
import { cookies } from 'next/headers';
|
|
import { type JournalEntrySummary } from '@/lib/journal';
|
|
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';
|
|
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();
|
|
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 {
|
|
coverUrl?: string;
|
|
blurDataURL?: string;
|
|
dominantColor?: string;
|
|
}
|
|
|
|
export default async function JournalIndexPage() {
|
|
const t = getServerDictionary();
|
|
const isAuthedAdmin = await isAdminAuthenticated();
|
|
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
|
|
// its cover and quotes its excerpt to every visitor regardless of the
|
|
// password on its body (GHSA-fvgv-97g3-wjr7).
|
|
const cookieStore = isAuthedAdmin ? null : await cookies();
|
|
const getCookie = (name: string) => cookieStore?.get(name)?.value;
|
|
const visibleEntries = isAuthedAdmin
|
|
? allEntries
|
|
: allEntries.filter((e) => {
|
|
if (e.frontmatter.draft) return false;
|
|
if (!e.frontmatter.password) return true;
|
|
return isAuthenticated(e.slug, getCookie, 'journal');
|
|
});
|
|
|
|
// Fetch cover image placeholders and URLs
|
|
const enrichedEntries: EnrichedJournalEntry[] = await Promise.all(
|
|
visibleEntries.map(async (entry) => {
|
|
if (!entry.frontmatter.coverAssetId) {
|
|
return entry;
|
|
}
|
|
|
|
try {
|
|
const asset = await immich.getAssetInfo(entry.frontmatter.coverAssetId);
|
|
const ph = asset ? assetPlaceholder(asset) : null;
|
|
return {
|
|
...entry,
|
|
coverUrl: imageUrl(asset ?? entry.frontmatter.coverAssetId, 'preview'),
|
|
blurDataURL: ph?.blurDataURL,
|
|
dominantColor: ph?.dominantColor,
|
|
};
|
|
} catch {
|
|
return {
|
|
...entry,
|
|
coverUrl: imageUrl(entry.frontmatter.coverAssetId, 'preview'),
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
return (
|
|
<div className="journal-index-container">
|
|
<div className="journal-index-header">
|
|
<BackLink href="/" label={t.common.backToGallery} />
|
|
<p className="journal-index-header__kicker" aria-hidden="true">
|
|
{t.journal.kicker}
|
|
</p>
|
|
<h1 className="journal-index-header__title">{t.journal.title}</h1>
|
|
<p className="journal-index-header__subtitle">{t.journal.subtitle}</p>
|
|
</div>
|
|
|
|
{enrichedEntries.length === 0 ? (
|
|
<div className="journal-empty-state">
|
|
<p>{t.journal.empty}</p>
|
|
</div>
|
|
) : (
|
|
<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">
|
|
<div
|
|
className="journal-card__cover"
|
|
style={{
|
|
...(entry.dominantColor ? { backgroundColor: entry.dominantColor } : {}),
|
|
}}
|
|
>
|
|
{entry.coverUrl ? (
|
|
<Image
|
|
src={entry.coverUrl}
|
|
alt={entry.frontmatter.title || entry.slug}
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 380px"
|
|
placeholder={entry.blurDataURL ? 'blur' : 'empty'}
|
|
blurDataURL={entry.blurDataURL}
|
|
/>
|
|
) : (
|
|
<div className="journal-card__cover-empty">
|
|
<IconBook size={32} aria-hidden="true" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="journal-card__content">
|
|
<div className="journal-card__meta">
|
|
{dateStr &&
|
|
(dateTime ? (
|
|
<time dateTime={dateTime}>{dateStr}</time>
|
|
) : (
|
|
<span>{dateStr}</span>
|
|
))}
|
|
{dateStr && entry.readingTimeMinutes && <span>•</span>}
|
|
{entry.readingTimeMinutes && (
|
|
<span>{t.journal.minRead(entry.readingTimeMinutes)}</span>
|
|
)}
|
|
{entry.frontmatter.draft && (
|
|
<span className="journal-card__draft-badge">{t.journal.draft}</span>
|
|
)}
|
|
</div>
|
|
|
|
<h2 className="journal-card__title">{entry.frontmatter.title || entry.slug}</h2>
|
|
|
|
{entry.frontmatter.subtitle && (
|
|
<p className="journal-card__subtitle">{entry.frontmatter.subtitle}</p>
|
|
)}
|
|
|
|
{/*
|
|
extractExcerpt() falls back to the subtitle when there is
|
|
one, so rendering both printed the same sentence twice.
|
|
*/}
|
|
{entry.excerpt && entry.excerpt !== entry.frontmatter.subtitle && (
|
|
<p className="journal-card__excerpt">{entry.excerpt}</p>
|
|
)}
|
|
|
|
<div className="journal-card__footer">
|
|
<span className="journal-card__read-more">{t.journal.readStory}</span>
|
|
{entry.frontmatter.author && (
|
|
<span style={{ opacity: 0.65 }}>
|
|
{t.journal.by(entry.frontmatter.author)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|