feat: plakat redesign phase 2 (desk with throwable objects, mobile card deck) (#75)

- New "Tisch" section replaces the old projects block on the home page
- Objects: project cards from Directus (flip on tap, link to detail page),
  thesis ticket with countdown, server card, currently-reading book from
  Hardcover (with skeleton), polaroid photo, sticker
- Desktop: Framer Motion drag with momentum, elastic table edges, tilt that
  follows throw velocity, bring-to-front, "Tidy up" and "Mess it up"
- Mobile: card deck, swipe the top card away (distance or velocity),
  prev/next buttons, live counter, tap to flip
- Both layouts are server rendered and switched by CSS, so content is
  visible without JavaScript and nothing jumps on hydration
- Taps after a drag no longer flip cards
- New named colors polaroid/bookcover instead of raw hex classes
- Texts in de/en, i18n key check extended

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-10-15 23:49:06 +02:00
committed by GitHub
parent 96ec2d867f
commit 62f2fb7742
10 changed files with 833 additions and 7 deletions
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import type { TischLabels, TischObject } from "./types";
interface ObjectFaceProps {
obj: TischObject;
labels: TischLabels;
flipped?: boolean;
}
const pad = (n: number) => String(n).padStart(2, "0");
// Rand und Rundung, die alle Objekte teilen
const edge = "h-full w-full overflow-hidden rounded-2xl border-2 border-ink";
/** Zeichnet ein Objekt. Größe und Position bestimmt der Container (Tisch oder Stapel). */
export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceProps) {
switch (obj.kind) {
case "project":
return (
<div className="h-full w-full [perspective:1200px]">
<div
className={`relative h-full w-full rounded-2xl transition-transform duration-[560ms] ease-in-out [transform-style:preserve-3d] ${
flipped ? "[transform:rotateY(180deg)]" : ""
}`}
>
<div
className={`${edge} absolute inset-0 flex flex-col justify-between p-4 [backface-visibility:hidden]`}
style={{ background: obj.color, color: obj.textColor }}
>
<div className="flex justify-between font-mono text-xs">
<span>({pad(obj.index + 1)})</span>
<span>{obj.year}</span>
</div>
<h3 className="font-display text-[2.4rem] font-extrabold leading-[0.88] tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_78] [color:inherit]">
{obj.title}
</h3>
<div className="font-mono text-xs leading-snug">
<div>{obj.tags.slice(0, 3).join(" · ")}</div>
<div className="mt-2 opacity-70">{labels.flip} ↻</div>
</div>
</div>
<div
className={`${edge} absolute inset-0 flex flex-col justify-between bg-paper p-4 text-ink [backface-visibility:hidden] [transform:rotateY(180deg)]`}
>
<div className="flex justify-between font-mono text-xs">
<span className="truncate pr-2">{obj.title}</span>
<span>{obj.year}</span>
</div>
<p className="line-clamp-6 text-[15px] leading-snug">{obj.description}</p>
<Link
href={obj.href}
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-ink px-3.5 py-2 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
onPointerDownCapture={(e) => e.stopPropagation()}
>
{labels.details} <span aria-hidden="true">→</span>
</Link>
</div>
</div>
</div>
);
case "ticket":
return (
<div className={`${edge} grid grid-cols-[1fr_76px] bg-ink text-paper`}>
<div className="flex flex-col justify-between p-4">
<span className="font-mono text-[11.5px] uppercase tracking-wide">{labels.ticketTitle}</span>
<div>
<div className="font-display text-[4.4rem] font-extrabold leading-[0.8] tracking-[-0.05em] text-signal [font-variant-numeric:tabular-nums]">
{obj.daysLeft}
</div>
<div className="mt-1 text-sm font-semibold">{labels.ticketDays}</div>
</div>
<span className="font-mono text-[11.5px] leading-snug">{labels.ticketJob}</span>
</div>
<div className="grid place-items-center border-l-2 border-dashed border-paper/30 font-mono text-[11px] uppercase tracking-[0.12em] text-signal [transform:rotate(180deg)] [writing-mode:vertical-rl]">
{labels.ticketStub}
</div>
</div>
);
case "server":
return (
<div className={`${edge} flex flex-col gap-3 bg-paper p-4 text-ink`}>
<h3 className="font-display text-2xl font-extrabold leading-none tracking-[-0.03em]">{labels.serverTitle}</h3>
{labels.servers.map((s) => (
<div key={s.name} className="grid grid-cols-[10px_1fr] items-center gap-2.5 text-sm">
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
<span>
{s.name}
<span className="block font-mono text-[11.5px] opacity-75">{s.detail}</span>
</span>
</div>
))}
<p className="mt-auto border-t-2 border-ink/15 pt-2.5 font-mono text-[11.5px] leading-snug">{labels.serverNote}</p>
</div>
);
case "book":
return <BookFace href={obj.href} labels={labels} />;
case "photo":
return (
<div className="flex h-full w-full flex-col rounded-md border-2 border-ink bg-polaroid p-2.5 pb-0 text-polaroid-ink">
<div className="relative w-full flex-1 overflow-hidden rounded-sm bg-paper-2">
<Image src="/images/me.jpg" alt="Dennis Konkol" fill sizes="220px" className="object-cover" draggable={false} />
</div>
<div className="py-2.5 text-center font-display text-lg font-bold">{labels.photoCaption}</div>
</div>
);
case "sticker":
return (
<div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink">
{labels.sticker}
</div>
);
}
}
interface CurrentBook {
title: string;
authors: string[];
image: string | null;
progress: number;
}
// Tisch und Stapel zeigen das Buch beide; so wird es nur einmal geladen
let bookRequest: Promise<CurrentBook | null> | null = null;
function loadCurrentBook(): Promise<CurrentBook | null> {
if (!bookRequest) {
bookRequest = fetch("/api/n8n/hardcover/currently-reading")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const list = data?.currentlyReading;
const first = Array.isArray(list) ? list[0] : list;
return first ?? null;
})
.catch(() => null);
}
return bookRequest;
}
function BookFace({ href, labels }: { href: string; labels: TischLabels }) {
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
useEffect(() => {
let alive = true;
loadCurrentBook().then((b) => {
if (alive) setBook(b);
});
return () => {
alive = false;
};
}, []);
const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0;
return (
<div className="relative h-full w-full overflow-hidden rounded-[6px_14px_14px_6px] border-2 border-ink bg-bookcover text-bookcover-text shadow-[inset_6px_0_0_rgba(255,255,255,0.08)]">
{book?.image && (
<Image src={book.image} alt="" fill sizes="200px" className="object-cover opacity-40" draggable={false} />
)}
<div className="relative flex h-full flex-col justify-between p-4">
<span className="font-mono text-xs uppercase tracking-wide">{labels.bookLabel}</span>
{book === undefined ? (
<div className="space-y-2" aria-hidden="true">
<div className="h-7 w-4/5 animate-pulse rounded bg-white/15" />
<div className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
</div>
) : book ? (
<div>
<div className="line-clamp-3 font-display text-[1.9rem] font-extrabold leading-[0.92] tracking-[-0.03em]">{book.title}</div>
<div className="mt-1.5 font-mono text-xs">{book.authors.join(", ")}</div>
</div>
) : (
<Link href={href} className="font-display text-2xl font-extrabold leading-tight underline decoration-2 underline-offset-4">
{labels.bookFallback}
</Link>
)}
<div>
<div className="font-mono text-xs">{book ? `${progress} %` : " "}</div>
<div className="mt-1.5 h-1 overflow-hidden rounded bg-white/20">
<div className="h-full origin-left bg-signal" style={{ transform: `scaleX(${progress / 100})` }} />
</div>
</div>
</div>
</div>
);
}