Files
portfolio/app/_ui/ProjectDetailClient.tsx
T
denshooter 20b719893c 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>
2026-10-16 00:00:58 +02:00

140 lines
5.2 KiB
TypeScript

"use client";
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;
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date?: string;
created_at?: string;
github?: string | null;
github_url?: string | null;
live?: string | null;
button_live_label?: string | null;
button_github_label?: string | null;
imageUrl?: string | null;
image_url?: string | null;
technologies?: string[];
};
export default function ProjectDetailClient({
project,
locale,
}: {
project: ProjectDetailData;
locale: string;
}) {
const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail");
const router = useRouter();
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();
}
};
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 (
<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>
<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="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>
);
}