62f2fb7742
- 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>
152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|