Files
portfolio/app/[locale]/books/page.tsx
denshooter 486555dbf2 feat: plakat redesign for subpages (projects, books, legal, privacy)
- 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>
2026-10-16 00:01:26 +02:00

122 lines
4.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}