Files
portfolio/app/components/tisch/ObjectFace.tsx
T
denshooter 70f378e720 feat: plakat redesign phase 2 (desk with throwable objects, mobile card deck)
- 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>
2026-10-15 18:47:42 +02:00

194 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}