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:
+110
-103
@@ -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">
|
||||
“{review.review.replace(/<[^>]*>/g, '')}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user