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
-98
View File
@@ -1,98 +0,0 @@
"use client";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "./ThemeToggle";
const Header = () => {
const [isOpen, setIsOpen] = useState(false);
const locale = useLocale();
const pathname = usePathname();
const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
const navItems = [
{ name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#projects" : `/${locale}#projects` },
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
];
return (
<>
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<nav className="animate-slide-down pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4">
{/* Logo Pill */}
<Link
href={`/${locale}`}
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform duration-150 ease-out active:scale-[0.97] shadow-lg"
>
<span className="font-black text-xs tracking-tighter">dk0</span>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
className="px-5 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
>
{item.name}
</Link>
))}
</div>
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
{/* Actions Pill */}
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
<Link
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
className="w-8 h-8 flex items-center justify-center text-[10px] font-black text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
>
{locale === "en" ? "DE" : "EN"}
</Link>
<ThemeToggle />
{/* Mobile Menu Toggle */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-8 h-8 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-white dark:hover:bg-stone-800 rounded-full transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] shadow-sm"
>
{isOpen ? <X size={14} /> : <Menu size={14} />}
</button>
</div>
</nav>
</div>
{/* Mobile Menu Overlay */}
<div
className={`fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden origin-top transition-[opacity,transform] duration-200 ease-out ${
isOpen
? "opacity-100 translate-y-0 scale-100 pointer-events-auto"
: "opacity-0 -translate-y-2 scale-[0.97] pointer-events-none"
}`}
>
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
>
{item.name}
</Link>
))}
</div>
</div>
</>
);
};
export default Header;
+38 -93
View File
@@ -24,73 +24,30 @@ interface ProjectThumbnailProps {
size?: "card" | "hero";
}
// Plakatflächen statt Pastellverläufe: jede Kategorie bekommt eine feste Ton-Kombination
// aus den Plakat-Tokens. Tinte/Papier tauschen im Dark Mode von selbst die Rollen.
const tones = {
signal: "bg-signal text-ink",
ink: "bg-ink text-paper",
paper: "bg-paper-2 text-ink",
ok: "bg-ok text-ink",
} as const;
const categoryThemes: Record<
string,
{
icon: LucideIcon;
gradient: string;
darkGradient: string;
iconColor: string;
darkIconColor: string;
tone: keyof typeof tones;
pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal";
}
> = {
"Web Development": {
icon: Code,
gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10",
iconColor: "text-blue-500",
darkIconColor: "dark:text-blue-400",
pattern: "circuit",
},
"Mobile Development": {
icon: Smartphone,
gradient: "from-liquid-mint/20 via-liquid-teal/10 to-liquid-sky/20",
darkGradient: "dark:from-liquid-mint/10 dark:via-liquid-teal/5 dark:to-liquid-sky/10",
iconColor: "text-emerald-500",
darkIconColor: "dark:text-emerald-400",
pattern: "waves",
},
"Web Application": {
icon: Globe,
gradient: "from-liquid-lavender/20 via-liquid-purple/10 to-liquid-pink/20",
darkGradient: "dark:from-liquid-lavender/10 dark:via-liquid-purple/5 dark:to-liquid-pink/10",
iconColor: "text-violet-500",
darkIconColor: "dark:text-violet-400",
pattern: "dots",
},
"Backend Development": {
icon: Cpu,
gradient: "from-liquid-amber/20 via-liquid-yellow/10 to-liquid-peach/20",
darkGradient: "dark:from-liquid-amber/10 dark:via-liquid-yellow/5 dark:to-liquid-peach/10",
iconColor: "text-amber-500",
darkIconColor: "dark:text-amber-400",
pattern: "grid",
},
"Full-Stack Development": {
icon: Boxes,
gradient: "from-liquid-teal/20 via-liquid-mint/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-teal/10 dark:via-liquid-mint/5 dark:to-liquid-lavender/10",
iconColor: "text-teal-500",
darkIconColor: "dark:text-teal-400",
pattern: "grid",
},
DevOps: {
icon: Shield,
gradient: "from-liquid-coral/20 via-liquid-rose/10 to-liquid-peach/20",
darkGradient: "dark:from-liquid-coral/10 dark:via-liquid-rose/5 dark:to-liquid-peach/10",
iconColor: "text-red-500",
darkIconColor: "dark:text-red-400",
pattern: "diagonal",
},
default: {
icon: Wrench,
gradient: "from-liquid-peach/20 via-liquid-rose/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-peach/10 dark:via-liquid-rose/5 dark:to-liquid-lavender/10",
iconColor: "text-stone-400",
darkIconColor: "dark:text-stone-500",
pattern: "dots",
},
"Web Development": { icon: Code, tone: "signal", pattern: "circuit" },
"Mobile Development": { icon: Smartphone, tone: "ink", pattern: "waves" },
"Web Application": { icon: Globe, tone: "paper", pattern: "dots" },
"Backend Development": { icon: Cpu, tone: "ink", pattern: "grid" },
"Full-Stack Development": { icon: Boxes, tone: "signal", pattern: "grid" },
DevOps: { icon: Shield, tone: "ok", pattern: "diagonal" },
default: { icon: Wrench, tone: "paper", pattern: "dots" },
};
const slugIcons: Record<string, LucideIcon> = {
@@ -106,7 +63,7 @@ const slugIcons: Record<string, LucideIcon> = {
function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
const patterns: Record<string, React.ReactNode> = {
dots: (
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-dots-${id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1.5" fill="currentColor" />
@@ -116,7 +73,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
grid: (
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-grid-${id}`} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1" />
@@ -126,7 +83,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
diagonal: (
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-diag-${id}`} x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
<line x1="0" y1="24" x2="24" y2="0" stroke="currentColor" strokeWidth="1" />
@@ -136,7 +93,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
circuit: (
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-circ-${id}`} x="0" y="0" width="60" height="60" patternUnits="userSpaceOnUse">
<path d="M0 30h20m20 0h20M30 0v20m0 20v20" stroke="currentColor" strokeWidth="0.8" fill="none" />
@@ -151,7 +108,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
waves: (
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-wave-${id}`} x="0" y="0" width="100" height="20" patternUnits="userSpaceOnUse">
<path d="M0 10 Q25 0 50 10 T100 10" fill="none" stroke="currentColor" strokeWidth="1" />
@@ -161,7 +118,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
terminal: (
<svg className="absolute inset-0 w-full h-full opacity-[0.08] dark:opacity-[0.06]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-term-${id}`} x="0" y="0" width="200" height="30" patternUnits="userSpaceOnUse">
<text x="4" y="18" fontFamily="monospace" fontSize="10" fill="currentColor">$_</text>
@@ -197,40 +154,28 @@ export default function ProjectThumbnail({
const Icon = theme.icon;
const isHero = size === "hero";
const displayTags = tags?.slice(0, 3) ?? [];
const displayTags = tags?.slice(0, isHero ? 5 : 3) ?? [];
return (
<div
className={`absolute inset-0 flex items-center justify-center bg-gradient-to-br ${theme.gradient} ${theme.darkGradient}`}
>
<div className={`absolute inset-0 flex flex-col justify-between ${tones[theme.tone]} ${isHero ? "p-5 sm:p-8" : "p-4 sm:p-5"}`}>
<PatternOverlay pattern={theme.pattern} id={uniqueId} />
<div className="relative z-10 flex flex-col items-center gap-3 sm:gap-4">
<div
className={`flex items-center justify-center rounded-2xl bg-white/60 dark:bg-white/10 backdrop-blur-sm border border-white/40 dark:border-white/10 ${theme.iconColor} ${theme.darkIconColor} ${isHero ? "w-20 h-20 sm:w-28 sm:h-28" : "w-14 h-14 sm:w-20 sm:h-20"}`}
>
<Icon className={isHero ? "w-10 h-10 sm:w-14 sm:h-14" : "w-7 h-7 sm:w-10 sm:h-10"} strokeWidth={1.5} />
</div>
<div className="relative z-10 flex items-start justify-between gap-3">
<span className="font-mono text-xs leading-snug sm:text-[13px]">{displayTags.join(" · ")}</span>
<span
className={`font-black tracking-tighter uppercase ${isHero ? "text-2xl sm:text-4xl md:text-5xl" : "text-sm sm:text-lg"} text-stone-400/80 dark:text-stone-500/80`}
className={`flex shrink-0 items-center justify-center rounded-full shadow-[inset_0_0_0_2px_currentColor] ${isHero ? "h-14 w-14 sm:h-16 sm:w-16" : "h-10 w-10"}`}
>
{title}
<Icon className={isHero ? "h-7 w-7 sm:h-8 sm:w-8" : "h-5 w-5"} strokeWidth={1.75} />
</span>
{displayTags.length > 0 && (
<div className={`flex flex-wrap justify-center gap-1.5 sm:gap-2 ${isHero ? "max-w-md" : "max-w-[200px]"}`}>
{displayTags.map((tag) => (
<span
key={tag}
className={`px-2 py-0.5 rounded-full bg-white/50 dark:bg-white/5 backdrop-blur-sm border border-white/30 dark:border-white/10 text-stone-500 dark:text-stone-400 font-medium ${isHero ? "text-xs sm:text-sm" : "text-[9px] sm:text-[10px]"}`}
>
{tag}
</span>
))}
</div>
)}
</div>
<span
className={`relative z-10 font-display font-extrabold leading-[0.86] tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_78] ${
isHero ? "text-[clamp(2.6rem,8vw,7rem)]" : "text-[clamp(1.8rem,4vw,2.8rem)]"
}`}
>
{title}
</span>
</div>
);
}
}