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>
This commit is contained in:
denshooter
2026-10-16 00:00:58 +02:00
parent 4f5089ce38
commit 20b719893c
22 changed files with 918 additions and 1005 deletions
+110 -103
View File
@@ -1,114 +1,121 @@
"use client";
import { Star, ArrowLeft } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Skeleton } from "@/app/components/ui/Skeleton";
import { BookReview } from "@/lib/directus";
import Header from "@/app/components/Header";
import Footer from "@/app/components/Footer";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
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 default function BooksPage() {
const locale = useLocale();
const t = useTranslations("books");
const common = useTranslations("common");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
export const revalidate = 300;
useEffect(() => {
const fetchBooks = async () => {
try {
const res = await fetch(`/api/book-reviews?locale=${locale}`);
const data = await res.json();
if (data.bookReviews) setReviews(data.bookReviews);
} catch {
if (process.env.NODE_ENV === "development") {
// console.error in dev only
}
} finally {
setLoading(false);
}
};
fetchBooks();
}, [locale]);
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 (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<Header />
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true" />
<main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6">
<div className="max-w-7xl mx-auto">
<div className="mb-20">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
<span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span>
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
{t("title")}<span className="text-liquid-purple">.</span>
</h1>
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{t("subtitle")}
</p>
</div>
<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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
))
) : reviews.length === 0 ? (
<div className="col-span-full text-center py-20 text-stone-400 dark:text-stone-500">
<Star size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-light">{t("empty")}</p>
</div>
) : (
reviews.map((review, i) => (
<ScrollFadeIn key={review.id} delay={i * 0.1}>
<div className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full hover:shadow-xl transition-shadow duration-300 ease-out">
{review.book_image && (
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800">
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" sizes="(max-width: 768px) 100vw, 33vw" />
</div>
)}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start gap-4 mb-4">
<h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3>
{review.rating && review.rating > 0 && (
<div className="flex items-center gap-1 bg-stone-50 dark:bg-stone-800 px-3 py-1 rounded-full border border-stone-100 dark:border-stone-700">
<Star size={12} className="fill-amber-400 text-amber-400" />
<span className="text-xs font-black">{review.rating}</span>
</div>
)}
</div>
<p className="text-stone-600 dark:text-stone-400 font-bold text-sm mb-6">{review.book_author}</p>
{review.review && (
<div className="mt-auto pt-6 border-t border-stone-100 dark:border-stone-800">
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
&ldquo;{review.review.replace(/<[^>]*>/g, '')}&rdquo;
</p>
</div>
)}
</div>
</div>
</ScrollFadeIn>
))
{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>
</main>
<Footer />
</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>
);
}
}
+12 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../legal-notice/page";
import SubpageShell from "@/app/_ui/SubpageShell";
import LegalNoticeClient from "@/app/_ui/LegalNoticeClient";
export async function generateMetadata({
params,
@@ -10,6 +11,7 @@ export async function generateMetadata({
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
return {
title: locale === "de" ? "Impressum – Dennis Konkol" : "Legal notice – Dennis Konkol",
alternates: {
canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
languages,
@@ -17,3 +19,12 @@ export async function generateMetadata({
};
}
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return (
<SubpageShell locale={locale}>
<LegalNoticeClient />
</SubpageShell>
);
}
+12 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../privacy-policy/page";
import SubpageShell from "@/app/_ui/SubpageShell";
import PrivacyPolicyClient from "@/app/_ui/PrivacyPolicyClient";
export async function generateMetadata({
params,
@@ -10,6 +11,7 @@ export async function generateMetadata({
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
return {
title: locale === "de" ? "Datenschutz – Dennis Konkol" : "Privacy policy – Dennis Konkol",
alternates: {
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
languages,
@@ -17,3 +19,12 @@ export async function generateMetadata({
};
}
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return (
<SubpageShell locale={locale}>
<PrivacyPolicyClient />
</SubpageShell>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
import SubpageShell from "@/app/_ui/SubpageShell";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
@@ -107,13 +108,13 @@ export default async function ProjectPage({
};
return (
<>
<SubpageShell locale={locale}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectDetailClient project={projectData} locale={locale} />
</>
</SubpageShell>
);
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
import SubpageShell from "@/app/_ui/SubpageShell";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjects as getDirectusProjects } from "@/lib/directus";
@@ -94,6 +95,10 @@ export default async function ProjectsPage({
return dateB - dateA;
});
return <ProjectsPageClient projects={allProjects} locale={locale} />;
return (
<SubpageShell locale={locale}>
<ProjectsPageClient projects={allProjects} locale={locale} />
</SubpageShell>
);
}