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
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useRef, useState } from "react";
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo, type Variants } from "framer-motion";
import ObjectFace from "./ObjectFace";
import type { TischLabels, TischObject } from "./types";
interface DeckProps {
objects: TischObject[];
labels: TischLabels;
}
const VISIBLE = 4;
// Ab dieser Strecke oder Geschwindigkeit fliegt die Karte weg (Emil: ein kurzer Wisch reicht)
const DISTANCE = 110;
const VELOCITY = 500;
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
const pad = (n: number) => String(n).padStart(2, "0");
type Exit = { kind: "fly"; dir: 1 | -1 } | { kind: "fade" };
// Bekommt den aktuellen Exit-Modus über `custom` von AnimatePresence
const exitVariants: Variants = {
leave: (e: Exit) =>
e.kind === "fly"
? { x: e.dir * 460, rotate: e.dir * 18, opacity: 0, transition: { duration: 0.3, ease: EASE_OUT } }
: { scale: 0.85, opacity: 0, transition: { duration: 0.2, ease: EASE_OUT } },
};
/** Handy: Kartenstapel. Oberste Karte zur Seite wischen, antippen zum Umdrehen. */
export default function Deck({ objects, labels }: DeckProps) {
const [order, setOrder] = useState(() => objects.map((_, i) => i));
// Wie die Karte geht, die den sichtbaren Stapel verlässt: oben weggeworfen oder hinten ausgeblendet
const [exit, setExit] = useState<Exit>({ kind: "fly", dir: 1 });
const next = (d: 1 | -1 = 1) => {
setExit({ kind: "fly", dir: d });
setOrder((o) => [...o.slice(1), o[0]]);
};
const prev = () => {
setExit({ kind: "fade" });
setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)]);
};
const visible = order.slice(0, VISIBLE);
return (
<div>
<p className="mb-2 px-4 font-mono text-[13px] sm:px-6">{labels.hintDeck}</p>
<div className="relative h-[440px] overflow-hidden">
<AnimatePresence initial={false} custom={exit}>
{visible
.map((idx, depth) => (
<DeckCard
key={objects[idx].id}
obj={objects[idx]}
labels={labels}
depth={depth}
enteringFromLeft={depth === 0 && exit.kind === "fade"}
onDismiss={next}
/>
))
.reverse()}
</AnimatePresence>
</div>
<div className="flex items-center justify-between px-4 pb-10 pt-1 sm:px-6">
<RoundButton label={labels.prev} onClick={prev}>
←
</RoundButton>
<span className="font-mono text-[13px] [font-variant-numeric:tabular-nums]" aria-live="polite">
{pad(order[0] + 1)} / {pad(objects.length)}
</span>
<RoundButton label={labels.next} onClick={() => next(1)}>
→
</RoundButton>
</div>
</div>
);
}
function RoundButton({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className="grid h-[52px] w-[52px] place-items-center rounded-full border-2 border-ink bg-paper text-xl font-semibold transition-[background-color,transform] duration-150 ease-out active:scale-95 active:bg-paper-2"
>
<span aria-hidden="true">{children}</span>
</button>
);
}
interface DeckCardProps {
obj: TischObject;
labels: TischLabels;
depth: number;
enteringFromLeft: boolean;
onDismiss: (dir: 1 | -1) => void;
}
function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardProps) {
const [flipped, setFlipped] = useState(false);
const dragged = useRef(false);
const x = useMotionValue(0);
const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
const isTop = depth === 0;
const canFlip = obj.kind === "project";
const restRotate = depth === 0 ? 0 : depth % 2 ? 3 : -3;
const handleDragEnd = (_: unknown, info: PanInfo) => {
if (Math.abs(info.offset.x) > DISTANCE || Math.abs(info.velocity.x) > VELOCITY) {
onDismiss(info.offset.x > 0 ? 1 : -1);
}
};
return (
<motion.div
className="absolute inset-x-0 top-4 mx-auto h-[390px] w-[min(300px,82vw)]"
style={{ zIndex: 10 - depth }}
initial={enteringFromLeft ? { x: -420, rotate: -14, opacity: 0 } : { scale: 0.85, y: 3 * 14, opacity: 0 }}
animate={{ x: 0, scale: 1 - Math.min(depth, 3) * 0.05, y: Math.min(depth, 3) * 14, rotate: restRotate, opacity: 1 }}
variants={exitVariants}
exit="leave"
transition={{ type: "spring", bounce: 0, duration: 0.45 }}
>
<motion.div
className={`h-full w-full ${obj.kind === "sticker" ? "rounded-full" : "rounded-2xl"} shadow-[6px_6px_0_rgb(var(--plakat-ink))] ${isTop ? "cursor-grab" : "pointer-events-none"}`}
style={{ x, rotate: dragRotate, touchAction: "pan-y" }}
drag={isTop ? "x" : false}
dragSnapToOrigin
dragElastic={0.9}
onPointerDown={() => {
dragged.current = false;
}}
onDragStart={() => {
dragged.current = true;
}}
onDragEnd={handleDragEnd}
onTap={isTop && canFlip ? () => !dragged.current && setFlipped((f) => !f) : undefined}
tabIndex={isTop && canFlip ? 0 : -1}
role={canFlip ? "button" : undefined}
aria-pressed={canFlip ? flipped : undefined}
aria-hidden={!isTop}
>
<ObjectFace obj={obj} labels={labels} flipped={flipped} />
</motion.div>
</motion.div>
);
}