486555dbf2
- 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>
140 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|