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 9e6ae866d0
commit 486555dbf2
22 changed files with 918 additions and 1005 deletions
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import RichTextClient from "../components/RichTextClient";
import { BackArrow, SubpageIntro, card, frame, ghost, monoLabel } from "./subpage";
import { useCmsPage } from "./useCmsPage";
export default function LegalNoticeClient() {
const locale = useLocale();
const t = useTranslations("common");
const tFooter = useTranslations("footer");
const cmsHtml = useCmsPage("legal-notice", locale);
const de = locale === "de";
return (
<>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {t("backToHome")}
</Link>
}
kicker="§ 5 TMG"
title={tFooter("legalNotice")}
/>
<div className="mt-10 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="plakat-prose max-w-[68ch]" />
) : (
<div className="plakat-prose max-w-[68ch]">
<h2>Angaben gemäß § 5 TMG</h2>
<p>
<strong>Dennis Konkol</strong>
<br />
Auf dem Ziegenbrink 2B
<br />
49082 Osnabrück, Deutschland
</p>
<h2>Haftungsausschluss</h2>
<p>
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und
Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.
</p>
</div>
)}
</div>
<aside className="grid content-start gap-6 lg:col-span-4">
<section className={`${frame} bg-signal p-5 text-ink md:p-6`} aria-labelledby="legal-contact">
<h2 id="legal-contact" className={monoLabel}>
{de ? "Direkter Kontakt" : "Direct contact"}
</h2>
<a
href="mailto:info@dk0.dev"
className="mt-3 block w-fit font-display text-[clamp(1.8rem,4vw,2.4rem)] font-extrabold leading-none tracking-[-0.04em] [overflow-wrap:anywhere] hover:underline hover:decoration-[3px] hover:underline-offset-[5px]"
>
info<span className="text-paper">@</span>dk0.dev
</a>
<p className="mt-4 text-[15px]">Osnabrück, {de ? "Deutschland" : "Germany"}</p>
</section>
<section className={`${card} p-5 md:p-6`} aria-labelledby="legal-review">
<h2 id="legal-review" className={monoLabel}>
{de ? "Zuletzt geprüft" : "Last review"}
</h2>
<p className="mt-2 font-display text-2xl font-extrabold tracking-[-0.03em]">{de ? "14. Mai 2025" : "May 14, 2025"}</p>
<p className="mt-3 text-[15px] leading-relaxed">
{de
? "Dieses Impressum gilt für alle Inhalte auf dk0.dev und die zugehörigen Social-Media-Profile."
: "This legal notice applies to all contents on dk0.dev and related social media profiles."}
</p>
</section>
</aside>
</div>
</>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import RichTextClient from "../components/RichTextClient";
import { useConsent } from "../components/ConsentProvider";
import { BackArrow, SubpageIntro, card, frame, ghost, monoLabel } from "./subpage";
import { useCmsPage } from "./useCmsPage";
export default function PrivacyPolicyClient() {
const locale = useLocale();
const t = useTranslations("common");
const tFooter = useTranslations("footer");
const { resetConsent } = useConsent();
const cmsHtml = useCmsPage("privacy-policy", locale);
const de = locale === "de";
const principles = de
? [
{ title: "Verschlüsselung", text: "Übertragung per SSL/TLS." },
{ title: "Transparenz", text: "Kein verstecktes Tracking." },
{ title: "DSGVO", text: "Ausgelegt auf die DSGVO." },
]
: [
{ title: "Encryption", text: "SSL/TLS secured data transfer." },
{ title: "Transparency", text: "No hidden tracking algorithms." },
{ title: "Compliance", text: "GDPR / DSGVO optimized." },
];
return (
<>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {t("backToHome")}
</Link>
}
kicker="DSGVO · GDPR"
title={tFooter("privacyPolicy")}
/>
<div className="mt-10 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="plakat-prose max-w-[68ch]" />
) : (
<div className="plakat-prose max-w-[68ch]">
<h2>Verantwortlicher</h2>
<p>
<strong>Dennis Konkol</strong>
<br />
Auf dem Ziegenbrink 2B
<br />
49082 Osnabrück, Deutschland
<br />
E-Mail: <a href="mailto:contact@dk0.dev">contact@dk0.dev</a>
</p>
<p>
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
Verantwortlichen.
</p>
<h2>Datenerfassung</h2>
<p>
Beim Zugriff auf diese Website werden automatisch Informationen allgemeiner Natur erfasst. Diese beinhalten
unter anderem:
</p>
<ul>
<li>IP-Adresse (in anonymisierter Form)</li>
<li>Uhrzeit und Datum des Zugriffs</li>
<li>Browsertyp und Betriebssystem</li>
<li>Referrer-URL (die zuvor besuchte Seite)</li>
</ul>
<p>
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
Rückschlüsse auf Ihre Person sind nicht möglich.
</p>
<h2>Analyse- und Tracking-Tools</h2>
<p>
Zur Analyse der Nutzung dieser Website setze ich <strong>Umami</strong> ein. Umami speichert keine
IP-Adressen oder Cookies. Alle erfassten Daten sind anonymisiert. Da ich Umami auf meinem eigenen Server
betreibe, erfolgt keine Weitergabe an Dritte. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO
(berechtigtes Interesse an der Analyse und Optimierung der Website).
</p>
<h2>Kontaktformular</h2>
<p>
Wenn Sie das Kontaktformular nutzen oder per E-Mail Kontakt aufnehmen, werden Ihre Angaben zur Bearbeitung
Ihrer Anfrage gespeichert. Diese Daten werden nicht an Dritte weitergegeben und nach Erfüllung des Zwecks
gelöscht. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
</p>
<h2>Social Media Links</h2>
<p>
Diese Website enthält Links zu GitHub und LinkedIn. Durch das Anklicken dieser Links gelten die
Datenschutzbestimmungen der jeweiligen Anbieter.
</p>
<h2>Weitergabe von Daten</h2>
<p>Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:</p>
<ul>
<li>Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt haben,</li>
<li>dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO erforderlich ist,</li>
<li>eine gesetzliche Verpflichtung nach Art. 6 Abs. 1 S. 1 lit. c DSGVO besteht, oder</li>
<li>die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung berechtigter Interessen erforderlich ist.</li>
</ul>
<h2>Ihre Rechte</h2>
<p>Sie haben gemäß DSGVO folgende Rechte:</p>
<ul>
<li>Art. 15 DSGVO: Auskunftsrecht über Ihre gespeicherten Daten</li>
<li>Art. 16 DSGVO: Recht auf Berichtigung unrichtiger Daten</li>
<li>Art. 17 DSGVO: Recht auf Löschung (soweit keine Aufbewahrungspflichten entgegenstehen)</li>
<li>Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung</li>
<li>Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
<li>Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung</li>
</ul>
<p>
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde richten:{" "}
<a href="https://www.bfdi.bund.de/" target="_blank" rel="noopener noreferrer">
bfdi.bund.de
</a>
</p>
<h2>Datensicherheit</h2>
<p>
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten zu schützen. Dazu gehören unter
anderem die SSL-Verschlüsselung. Diese Verschlüsselung erkennen Sie an dem Schloss-Symbol in der
Adresszeile Ihres Browsers und an der URL, die mit &ldquo;https://&rdquo; beginnt.
</p>
<h2>Änderungen</h2>
<p>
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den gesetzlichen Anforderungen zu entsprechen.
Die jeweils aktuelle Version finden Sie auf dieser Seite.
</p>
<p className="font-mono text-[13px]">Letzte Aktualisierung: Mai 2025</p>
</div>
)}
</div>
<aside className="grid content-start gap-6 lg:col-span-4">
<section className={`${frame} bg-ink p-5 text-paper md:p-6`} aria-labelledby="privacy-principles">
<h2 id="privacy-principles" className={monoLabel}>
{de ? "Grundsätze" : "Principles"}
</h2>
<dl className="mt-3">
{principles.map((p) => (
<div key={p.title} className="grid grid-cols-[10px_1fr] items-baseline gap-3 border-b border-paper/20 py-3 last:border-b-0">
<span className="h-2.5 w-2.5 rounded-full bg-signal" aria-hidden="true" />
<div>
<dt className="font-display text-xl font-extrabold tracking-[-0.02em]">{p.title}</dt>
<dd className="mt-0.5 text-[15px] opacity-80">{p.text}</dd>
</div>
</div>
))}
</dl>
</section>
<section className={`${card} p-5 md:p-6`} aria-labelledby="privacy-settings">
<h2 id="privacy-settings" className={`${monoLabel} flex items-center gap-2`}>
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
{de ? "Verbindung" : "Connection"}
</h2>
<p className="mt-3 font-display text-2xl font-extrabold leading-tight tracking-[-0.03em]">
{de ? "Deine Verbindung ist verschlüsselt." : "Your connection is private and secure."}
</p>
<button type="button" onClick={resetConsent} className={`${ghost} mt-4`} title={tFooter("privacySettingsTitle")}>
{tFooter("privacySettings")}
</button>
</section>
</aside>
</div>
</>
);
}
+89 -125
View File
@@ -1,12 +1,12 @@
"use client";
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ProjectThumbnail from "@/app/components/ProjectThumbnail";
import { BackArrow, SubpageIntro, btn, card, frame, ghost, monoLabel } from "./subpage";
export type ProjectDetailData = {
id: number;
@@ -39,137 +39,101 @@ export default function ProjectDetailClient({
const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail");
const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => {
// Prüfen, ob wir eine History haben (von Home gekommen)
if (typeof window !== 'undefined' && window.history.length > 1) {
setCanGoBack(true);
}
}, []);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
// Wenn wir direkt auf die Seite gekommen sind (Deep Link), gehen wir zur Projektliste
// Ansonsten nutzen wir den Browser-Back, um an die exakte Stelle der Home oder Liste zurückzukehren
if (canGoBack) {
const listHref = `/${locale}/projects`;
const handleBack = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Kommt man von der eigenen Seite, geht es per Browser-Zurück an die exakte Stelle
// (Startseite oder Liste). Bei Deep Links, ohne JS oder mit Modifier-Taste gilt der Link selbst.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
const fromHere = document.referrer && new URL(document.referrer).origin === window.location.origin;
if (fromHere && window.history.length > 1) {
e.preventDefault();
router.back();
} else {
router.push(`/${locale}/projects`);
}
};
const liveUrl = project.live && project.live !== "#" ? project.live : null;
const githubUrl = project.github && project.github !== "#" ? project.github : null;
const rawDate = project.date || project.created_at;
const year = rawDate && !Number.isNaN(new Date(rawDate).getFullYear()) ? String(new Date(rawDate).getFullYear()) : null;
const stack = project.technologies?.length ? project.technologies : project.tags;
return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Navigation - Intelligent Back */}
<button
onClick={handleBack}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
<span className="font-bold uppercase tracking-widest text-xs">
{tCommon("back")}
</span>
</button>
<article>
<SubpageIntro
back={
<Link href={listHref} onClick={handleBack} className={ghost}>
<BackArrow /> {tCommon("back")}
</Link>
}
kicker={[project.category, year].filter(Boolean).join(" · ")}
title={project.title}
>
<p>{project.description}</p>
</SubpageIntro>
{/* Title Section */}
<div className="mb-20">
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
{project.title}<span className="text-liquid-mint">.</span>
</h1>
<p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
{project.description}
</p>
</div>
{/* Feature Image Box */}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-4 md:p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm mb-12 overflow-hidden">
<div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
{project.imageUrl ? (
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
) : (
<ProjectThumbnail
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="hero"
/>
)}
<div className="relative mt-8 aspect-[4/3] overflow-hidden rounded-[18px] border-2 border-ink shadow-[8px_8px_0_rgb(var(--plakat-ink))] sm:aspect-video md:mt-12">
{project.imageUrl ? (
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="(max-width: 1400px) 100vw, 1400px" />
) : (
<div aria-hidden="true">
<ProjectThumbnail title={project.title} category={project.category} tags={project.tags} slug={project.slug} size="hero" />
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="lg:col-span-8 space-y-8">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
</div>
</div>
<div className="lg:col-span-4 space-y-8">
{/* Quick Links Box - Only show if links exist */}
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
<div className="space-y-4">
{project.live && project.live !== "#" && (
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black active:scale-[0.97] transition-transform duration-150 ease-out group">
<span>{project.button_live_label || tDetail("liveDemo")}</span>
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform duration-200 ease-out" />
</a>
)}
{project.github && project.github !== "#" && (
<a href={project.github} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-stone-800 text-white border border-stone-700 rounded-2xl font-black hover:bg-stone-700 transition-colors group">
<span>{project.button_github_label || tDetail("viewSource")}</span>
<GithubIcon size={20} className="group-hover:rotate-12 transition-transform" />
</a>
)}
</div>
</div>
)}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-stone-400">Stack</h3>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700">
{tag}
</span>
))}
</div>
</div>
</div>
</div>
)}
</div>
</div>
<div className="mt-12 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{project.content ? (
<div className="plakat-prose max-w-[68ch]">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
) : (
<p className="max-w-[68ch] text-lg leading-relaxed">{project.description}</p>
)}
</div>
<aside className="grid content-start gap-6 lg:sticky lg:top-24 lg:col-span-4 lg:self-start">
{(liveUrl || githubUrl) && (
<section className={`${frame} bg-signal p-5 text-ink md:p-6`} aria-labelledby="project-links">
<h2 id="project-links" className={monoLabel}>
{tDetail("links")}
</h2>
<div className="mt-4 flex flex-wrap gap-2">
{liveUrl && (
<a href={liveUrl} target="_blank" rel="noopener noreferrer" className={btn}>
{project.button_live_label || tDetail("liveDemo")} <span aria-hidden="true">↗</span>
</a>
)}
{githubUrl && (
<a href={githubUrl} target="_blank" rel="noopener noreferrer" className={`${ghost} px-5 py-3 text-[15px] font-semibold`}>
{project.button_github_label || tDetail("viewSource")} <span aria-hidden="true">↗</span>
</a>
)}
</div>
</section>
)}
{stack.length > 0 && (
<section className={`${card} p-5 md:p-6`} aria-labelledby="project-stack">
<h2 id="project-stack" className={monoLabel}>
{tDetail("techStack")}
</h2>
<ul className="mt-4 flex flex-wrap gap-2">
{stack.map((tag) => (
<li key={tag} className="rounded-full px-3 py-1.5 text-sm font-medium shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))]">
{tag}
</li>
))}
</ul>
</section>
)}
<Link href={listHref} className={`${ghost} w-fit`}>
<BackArrow /> {tCommon("backToProjects")}
</Link>
</aside>
</div>
</article>
);
}
+144 -112
View File
@@ -1,13 +1,12 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "../components/ui/Skeleton";
import { useTranslations } from "next-intl";
import ProjectThumbnail from "@/app/components/ProjectThumbnail";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "./subpage";
export type ProjectListItem = {
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
@@ -21,6 +20,18 @@ export type ProjectListItem = {
imageUrl?: string | null;
};
function yearOf(project: ProjectListItem): string | null {
const raw = project.date || project.createdAt;
if (!raw) return null;
const year = new Date(raw).getFullYear();
return Number.isNaN(year) ? null : String(year);
}
const filterPill = (active: boolean) =>
`rounded-full px-4 py-2 text-sm font-medium transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${
active ? "bg-ink text-paper" : "shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] hover:bg-ink hover:text-paper"
}`;
export default function ProjectsPageClient({
projects,
locale,
@@ -33,13 +44,6 @@ export default function ProjectsPageClient({
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Simulate initial load for smoother entrance or handle actual fetch if needed
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
}, []);
const categories = useMemo(() => {
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
@@ -54,115 +58,143 @@ export default function ProjectsPageClient({
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
(p) =>
p.title.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags.some((t) => t.toLowerCase().includes(query)),
);
}
return result;
}, [projects, selectedCategory, searchQuery]);
const resetFilters = () => {
setSelectedCategory("all");
setSearchQuery("");
};
return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-24">
<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">{tCommon("backToHome")}</span>
<>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {tCommon("backToHome")}
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
{tList("title")}<span className="text-liquid-mint">.</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">
{tList("intro")}
</p>
</div>
}
kicker={`${String(projects.length).padStart(2, "0")} ${locale === "de" ? "Projekte" : "projects"}`}
title={tList("title")}
>
<p>{tList("intro")}</p>
</SubpageIntro>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${
selectedCategory === cat
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
}`}
>
{cat === 'all' ? tList('all') : cat}
</button>
))}
</div>
<div className="relative w-full md:w-80">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<input
type="text"
placeholder={tList("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-sm"
/>
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))
) : (
filteredProjects.map((project) => (
<motion.div key={project.id} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-60px" }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}>
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-shadow duration-300 ease-out flex flex-col">
{project.imageUrl ? (
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-[1.03] transition-transform duration-500 ease-out" />
</div>
) : (
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<ProjectThumbnail
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="card"
/>
</div>
)}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start mb-4">
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-colors duration-200">
<ArrowUpRight size={20} />
</div>
</div>
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
<div className="mt-auto flex flex-wrap gap-2">
{project.tags.slice(0, 3).map(tag => (
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
))}
</div>
</div>
</div>
</Link>
</motion.div>
)))}
{/* Filter: Kategorien als Pillen, Suche als Tinten-Feld */}
<div className="mt-8 flex flex-col gap-4 md:mt-10 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2" role="group" aria-label={locale === "de" ? "Kategorien" : "Categories"}>
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setSelectedCategory(cat)}
aria-pressed={selectedCategory === cat}
className={filterPill(selectedCategory === cat)}
>
{cat === "all" ? tList("all") : cat}
</button>
))}
</div>
<label className="relative block w-full md:w-80">
<span className="sr-only">{tList("searchPlaceholder")}</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="pointer-events-none absolute left-4 top-1/2 h-[18px] w-[18px] -translate-y-1/2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
type="search"
placeholder={tList("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-full border-2 border-ink bg-paper py-3 pl-11 pr-5 text-base text-ink placeholder:text-ink/60 transition-shadow duration-150 ease-out focus:shadow-[4px_4px_0_rgb(var(--plakat-ink))] focus:outline-none"
/>
</label>
</div>
</div>
{filteredProjects.length === 0 ? (
<div className={`${card} mt-10 flex flex-col items-start gap-4 p-6 md:p-8`}>
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">
{tList("noResults")}
</p>
<button type="button" onClick={resetFilters} className={ghost}>
{tList("clearFilters")}
</button>
</div>
) : (
<ul className="mt-10 grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-3">
{filteredProjects.map((project, i) => {
const year = yearOf(project);
return (
<li key={project.id} className="min-w-0">
<ScrollFadeIn delay={Math.min(i, 5) * 0.05} className="h-full">
<Link
href={`/${locale}/projects/${project.slug}`}
className={`${card} group flex h-full flex-col overflow-hidden transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-1 hover:shadow-[8px_10px_0_rgb(var(--plakat-ink))] active:scale-[0.97]`}
>
<div className="relative aspect-[16/10] border-b-2 border-ink" aria-hidden="true">
{project.imageUrl ? (
<Image
src={project.imageUrl}
alt=""
fill
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
className="object-cover"
/>
) : (
<ProjectThumbnail
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="card"
/>
)}
</div>
<div className="flex flex-1 flex-col gap-3 p-5 md:p-6">
<p className={monoLabel}>{[project.category, year].filter(Boolean).join(" · ")}</p>
<div className="flex items-start justify-between gap-4">
<h2 className="min-w-0 hyphens-auto break-words font-display text-[clamp(1.7rem,3vw,2.3rem)] font-extrabold leading-[0.95] tracking-[-0.04em]">
{project.title}
</h2>
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-lg shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-colors duration-150 ease-out group-hover:bg-signal group-hover:text-ink"
>
↗
</span>
</div>
<p className="line-clamp-3 text-[15px] leading-relaxed">{project.description}</p>
{project.tags.length > 0 && (
<ul className="mt-auto flex flex-wrap gap-1.5 pt-2" aria-label="Stack">
{project.tags.slice(0, 4).map((tag) => (
<li key={tag} className="rounded-full bg-paper-2 px-2.5 py-1 font-mono text-xs">
{tag}
</li>
))}
</ul>
)}
</div>
</Link>
</ScrollFadeIn>
</li>
);
})}
</ul>
)}
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import Header from "../components/Header.server";
import { FooterClient } from "../components/ClientWrappers";
import { getFooterTranslations } from "@/lib/translations-loader";
interface SubpageShellProps {
locale: string;
children: ReactNode;
}
/**
* Rahmen für alle Unterseiten: derselbe Kopf wie auf der Startseite
* (dort als Papierstreifen), Papier als Grund, Tinten-Footer.
*/
export default async function SubpageShell({ locale, children }: SubpageShellProps) {
const footerT = await getFooterTranslations(locale);
return (
<div id="top" className="min-h-screen bg-paper text-ink">
<Header locale={locale} />
<main className="px-4 pb-20 pt-24 sm:px-6 md:pb-28 md:pt-32 lg:px-10">
<div className="mx-auto max-w-[1400px]">{children}</div>
</main>
<FooterClient locale={locale} translations={footerT} />
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
// Gemeinsame Bausteine der Unterseiten im Plakat-Stil. Bewusst ohne "use client",
// damit Server- und Client-Seiten sie gleichermaßen nutzen können.
export const btn =
"inline-flex items-center gap-2 rounded-full bg-ink px-5 py-3 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]";
export const ghost =
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]";
/** Rahmen mit hartem Versatzschatten, ohne Hintergrund (für farbige Karten) */
export const frame = "rounded-[18px] border-2 border-ink shadow-[6px_6px_0_rgb(var(--plakat-ink))]";
export const card = `${frame} bg-paper`;
export const monoLabel = "font-mono text-[13px] leading-snug";
interface SubpageIntroProps {
/** Zurück-Link oben links */
back: ReactNode;
/** Kleine Zeile oben rechts, z. B. Kategorie oder Rechtsgrundlage */
kicker?: ReactNode;
title: ReactNode;
children?: ReactNode;
}
/** Kopf einer Unterseite: Zurück-Pille, große Plakat-Zeile, Einleitung. */
export function SubpageIntro({ back, kicker, title, children }: SubpageIntroProps) {
return (
<header className="border-b-2 border-ink pb-8 md:pb-12">
<div className="flex flex-wrap items-center justify-between gap-3">
{back}
{kicker && <p className={monoLabel}>{kicker}</p>}
</div>
<h1 className="mt-8 hyphens-auto break-words text-balance font-display text-[clamp(3rem,11vw,9.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80] md:mt-12">
{title}
<span className="text-signal">.</span>
</h1>
{children && <div className="mt-6 max-w-[52ch] text-lg leading-relaxed md:text-xl">{children}</div>}
</header>
);
}
/** Pfeil nach links für Zurück-Links, rein dekorativ */
export function BackArrow() {
return <span aria-hidden="true">←</span>;
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
/**
* Lädt eine Inhaltsseite (Impressum, Datenschutz) aus dem CMS nach.
* Bis dahin und ohne JavaScript bleibt der eingebaute Text stehen.
*/
export function useCmsPage(key: string, locale: string): string | null {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/content/page?key=${encodeURIComponent(key)}&locale=${encodeURIComponent(locale)}`);
const data = await res.json();
if (!cancelled && data?.content?.html && data?.content?.locale === locale) {
setHtml(data.content.html as string);
}
} catch {}
})();
return () => {
cancelled = true;
};
}, [key, locale]);
return html;
}