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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user