486555dbf2
- Shared SubpageShell (HeaderClient paper strip + ink footer) and SubpageIntro, btn/ghost/card helpers in app/_ui/subpage.tsx - Projects list: poster cards with hard ink shadows, filter pills, ink search field; no fake 800ms skeleton and no framer-motion opacity:0 in SSR - Project detail: poster hero, .plakat-prose markdown, links/stack aside, back link works without JS - Books: server-side fetch (visible without JS), cover + rating pills - Legal notice / privacy: moved to app/_ui/*Client.tsx, root routes removed (middleware always redirects to /[locale]), consent reset via useConsent - ProjectThumbnail rebuilt on Plakat tokens - Old Header.tsx + test removed; brown/cream/sand palettes and unused glass-panel/admin-glass(-light)/liquid-bg-blob/float CSS dropped (liquid palette, glass-card, admin-glass-card stay for admin + 404) - .plakat-prose replaces the missing typography plugin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
import Link from "next/link";
|
||
import Image from "next/image";
|
||
import type { Metadata } from "next";
|
||
import { getTranslations } from "next-intl/server";
|
||
import { getBookReviews, type BookReview } from "@/lib/directus";
|
||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||
import SubpageShell from "@/app/_ui/SubpageShell";
|
||
import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "@/app/_ui/subpage";
|
||
|
||
export const revalidate = 300;
|
||
|
||
export async function generateMetadata({
|
||
params,
|
||
}: {
|
||
params: Promise<{ locale: string }>;
|
||
}): Promise<Metadata> {
|
||
const { locale } = await params;
|
||
const isDe = locale === "de";
|
||
return {
|
||
title: isDe ? "Bibliothek – Dennis Konkol" : "Library – Dennis Konkol",
|
||
alternates: {
|
||
canonical: toAbsoluteUrl(`/${locale}/books`),
|
||
languages: getLanguageAlternates({ pathWithoutLocale: "books" }),
|
||
},
|
||
};
|
||
}
|
||
|
||
export default async function BooksPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ locale: string }>;
|
||
}) {
|
||
const { locale } = await params;
|
||
const t = await getTranslations({ locale, namespace: "books" });
|
||
const common = await getTranslations({ locale, namespace: "common" });
|
||
|
||
// Serverseitig laden, damit die Liste auch ohne JavaScript dasteht
|
||
let reviews: BookReview[] = [];
|
||
try {
|
||
reviews = (await getBookReviews(locale)) ?? [];
|
||
} catch (err) {
|
||
if (process.env.NODE_ENV === "development") console.error("Book reviews fetch failed:", err);
|
||
}
|
||
|
||
return (
|
||
<SubpageShell locale={locale}>
|
||
<SubpageIntro
|
||
back={
|
||
<Link href={`/${locale}`} className={ghost}>
|
||
<BackArrow /> {common("back")}
|
||
</Link>
|
||
}
|
||
kicker={reviews.length > 0 ? `${String(reviews.length).padStart(2, "0")} ${locale === "de" ? "Bücher" : "books"}` : undefined}
|
||
title={t("title")}
|
||
>
|
||
<p>{t("subtitle")}</p>
|
||
</SubpageIntro>
|
||
|
||
{reviews.length === 0 ? (
|
||
<div className={`${card} mt-10 p-6 md:p-8`}>
|
||
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">{t("empty")}</p>
|
||
</div>
|
||
) : (
|
||
<ul className="mt-10 grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
|
||
{reviews.map((review) => (
|
||
<li key={review.id} className="min-w-0">
|
||
<BookCard review={review} locale={locale} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</SubpageShell>
|
||
);
|
||
}
|
||
|
||
function BookCard({ review, locale }: { review: BookReview; locale: string }) {
|
||
const text = review.review?.replace(/<[^>]*>/g, "").trim();
|
||
const finished = review.finished_at ? new Date(review.finished_at) : null;
|
||
const finishedLabel =
|
||
finished && !Number.isNaN(finished.getTime())
|
||
? finished.toLocaleDateString(locale === "de" ? "de-DE" : "en-GB", { month: "short", year: "numeric" })
|
||
: null;
|
||
|
||
return (
|
||
<article className={`${card} flex h-full flex-col gap-4 p-5 md:p-6`}>
|
||
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-4">
|
||
<div className="relative h-[128px] overflow-hidden rounded-[3px_6px_6px_3px] border-2 border-ink bg-bookcover">
|
||
{review.book_image && <Image src={review.book_image} alt="" fill sizes="88px" className="object-cover" />}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<h2 className="hyphens-auto break-words font-display text-[1.45rem] font-extrabold leading-[1.02] tracking-[-0.03em] sm:text-[1.6rem]">
|
||
{review.book_title}
|
||
</h2>
|
||
<p className={`${monoLabel} mt-2`}>{review.book_author}</p>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
{review.rating > 0 && (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-ink px-2.5 py-1 font-mono text-xs text-paper">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3 w-3 fill-signal">
|
||
<path d="M12 1.5l2.6 7.9 7.9 2.6-7.9 2.6-2.6 7.9-2.6-7.9-7.9-2.6 7.9-2.6z" />
|
||
</svg>
|
||
<span>
|
||
<span className="sr-only">{locale === "de" ? "Bewertung: " : "Rating: "}</span>
|
||
{review.rating}/5
|
||
</span>
|
||
</span>
|
||
)}
|
||
{finishedLabel && <span className="font-mono text-xs">{finishedLabel}</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{text && (
|
||
<blockquote className="mt-auto border-t-2 border-ink pt-4 text-[15px] leading-relaxed">
|
||
<span aria-hidden="true" className="font-display text-2xl font-extrabold leading-none text-signal">
|
||
{locale === "de" ? "„" : "“"}
|
||
</span>
|
||
{text}
|
||
</blockquote>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|