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:
+144
-112
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user