20b719893c
- 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>
182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useId } from "react";
|
|
import {
|
|
Terminal,
|
|
Smartphone,
|
|
Globe,
|
|
Code,
|
|
LayoutDashboard,
|
|
MessageSquare,
|
|
Cloud,
|
|
Wrench,
|
|
Cpu,
|
|
Shield,
|
|
Boxes,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
interface ProjectThumbnailProps {
|
|
title: string;
|
|
category?: string;
|
|
tags?: string[];
|
|
slug?: string;
|
|
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;
|
|
tone: keyof typeof tones;
|
|
pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal";
|
|
}
|
|
> = {
|
|
"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> = {
|
|
"kernel-panic-404-interactive-terminal": Terminal,
|
|
"portfolio-website": LayoutDashboard,
|
|
"real-time-chat-application": MessageSquare,
|
|
"weather-forecast-app": Cloud,
|
|
"clarity": Smartphone,
|
|
"e-commerce-platform-api": Boxes,
|
|
"task-management-dashboard": LayoutDashboard,
|
|
};
|
|
|
|
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.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" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill={`url(#pat-dots-${id})`} />
|
|
</svg>
|
|
),
|
|
grid: (
|
|
<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" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill={`url(#pat-grid-${id})`} />
|
|
</svg>
|
|
),
|
|
diagonal: (
|
|
<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" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill={`url(#pat-diag-${id})`} />
|
|
</svg>
|
|
),
|
|
circuit: (
|
|
<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" />
|
|
<circle cx="30" cy="30" r="3" fill="currentColor" />
|
|
<circle cx="10" cy="30" r="2" fill="currentColor" />
|
|
<circle cx="50" cy="30" r="2" fill="currentColor" />
|
|
<circle cx="30" cy="10" r="2" fill="currentColor" />
|
|
<circle cx="30" cy="50" r="2" fill="currentColor" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill={`url(#pat-circ-${id})`} />
|
|
</svg>
|
|
),
|
|
waves: (
|
|
<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" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill={`url(#pat-wave-${id})`} />
|
|
</svg>
|
|
),
|
|
terminal: (
|
|
<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>
|
|
<text x="50" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.4">│</text>
|
|
<text x="70" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.6">404</text>
|
|
<text x="110" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.4">│</text>
|
|
<text x="130" y="18" fontFamily="monospace" fontSize="10" fill="currentColor" opacity="0.3">ERR</text>
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill={`url(#pat-term-${id})`} />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
return patterns[pattern] || patterns.dots;
|
|
}
|
|
|
|
export default function ProjectThumbnail({
|
|
title,
|
|
category,
|
|
tags,
|
|
slug,
|
|
size = "card",
|
|
}: ProjectThumbnailProps) {
|
|
const uniqueId = useId();
|
|
const theme = useMemo(() => {
|
|
if (slug && slugIcons[slug]) {
|
|
const matchedTheme = categoryThemes[category || ""] || categoryThemes.default;
|
|
return { ...matchedTheme, icon: slugIcons[slug] };
|
|
}
|
|
return categoryThemes[category || ""] || categoryThemes.default;
|
|
}, [category, slug]);
|
|
|
|
const Icon = theme.icon;
|
|
const isHero = size === "hero";
|
|
const displayTags = tags?.slice(0, isHero ? 5 : 3) ?? [];
|
|
|
|
return (
|
|
<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 items-start justify-between gap-3">
|
|
<span className="font-mono text-xs leading-snug sm:text-[13px]">{displayTags.join(" · ")}</span>
|
|
<span
|
|
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"}`}
|
|
>
|
|
<Icon className={isHero ? "h-7 w-7 sm:h-8 sm:w-8" : "h-5 w-5"} strokeWidth={1.75} />
|
|
</span>
|
|
</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>
|
|
);
|
|
}
|