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,206 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
useTransform,
|
||||
useVelocity,
|
||||
type MotionValue,
|
||||
} from "framer-motion";
|
||||
import ObjectFace from "./ObjectFace";
|
||||
import { BOARD_SIZE, BOARD_SPOTS, type TischLabels, type TischObject } from "./types";
|
||||
|
||||
interface Handle {
|
||||
el: HTMLDivElement;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
base: MotionValue<number>;
|
||||
spotRotation: number;
|
||||
}
|
||||
|
||||
interface BoardProps {
|
||||
objects: TischObject[];
|
||||
labels: TischLabels;
|
||||
}
|
||||
|
||||
const TABLE_HEIGHT = 1080;
|
||||
const SETTLE = { type: "spring", bounce: 0, duration: 0.6 } as const;
|
||||
|
||||
/** Desktop: freier Tisch. Objekte lassen sich werfen, federn am Rand zurück und lassen sich aufräumen. */
|
||||
export default function Board({ objects, labels }: BoardProps) {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const handles = useRef(new Map<string, Handle>());
|
||||
const [order, setOrder] = useState(() => objects.map((o) => o.id));
|
||||
|
||||
const bringToFront = useCallback((id: string) => {
|
||||
setOrder((prev) => (prev[prev.length - 1] === id ? prev : [...prev.filter((x) => x !== id), id]));
|
||||
}, []);
|
||||
|
||||
const tidy = () => {
|
||||
const table = tableRef.current;
|
||||
if (!table) return;
|
||||
const width = table.clientWidth;
|
||||
const gap = 22;
|
||||
let cx = 0;
|
||||
let cy = 28;
|
||||
let rowH = 0;
|
||||
objects.forEach((obj, i) => {
|
||||
const h = handles.current.get(obj.id);
|
||||
if (!h) return;
|
||||
const { w, h: oh } = BOARD_SIZE[obj.kind];
|
||||
if (cx + w > width && cx > 0) {
|
||||
cx = 0;
|
||||
cy += rowH + gap;
|
||||
rowH = 0;
|
||||
}
|
||||
const tx = cx - h.el.offsetLeft;
|
||||
const ty = cy - h.el.offsetTop;
|
||||
cx += w + gap;
|
||||
rowH = Math.max(rowH, oh);
|
||||
const delay = i * 0.03;
|
||||
animate(h.x, tx, { ...SETTLE, delay });
|
||||
animate(h.y, ty, { ...SETTLE, delay });
|
||||
animate(h.base, 0, { ...SETTLE, delay });
|
||||
});
|
||||
};
|
||||
|
||||
const mess = () => {
|
||||
objects.forEach((obj, i) => {
|
||||
const h = handles.current.get(obj.id);
|
||||
if (!h) return;
|
||||
const delay = i * 0.03;
|
||||
animate(h.x, 0, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||
animate(h.y, 0, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||
animate(h.base, h.spotRotation, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 lg:px-10">
|
||||
<p className="font-mono text-[13px]">{labels.hintBoard}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={mess}
|
||||
className="rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]"
|
||||
>
|
||||
{labels.mess}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={tidy}
|
||||
className="rounded-full bg-ink px-5 py-3 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
|
||||
>
|
||||
{labels.tidy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={tableRef}
|
||||
className="relative mx-4 border-t-2 border-ink bg-[radial-gradient(circle_at_1px_1px,rgb(var(--plakat-ink)/0.16)_1px,transparent_0)] [background-size:22px_22px] sm:mx-6 lg:mx-10"
|
||||
style={{ height: TABLE_HEIGHT }}
|
||||
>
|
||||
{objects.map((obj, i) => (
|
||||
<BoardItem
|
||||
key={obj.id}
|
||||
obj={obj}
|
||||
labels={labels}
|
||||
spot={BOARD_SPOTS[i % BOARD_SPOTS.length]}
|
||||
z={order.indexOf(obj.id) + 1}
|
||||
tableRef={tableRef}
|
||||
onFront={() => bringToFront(obj.id)}
|
||||
register={(h) => {
|
||||
if (h) handles.current.set(obj.id, h);
|
||||
else handles.current.delete(obj.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoardItemProps {
|
||||
obj: TischObject;
|
||||
labels: TischLabels;
|
||||
spot: { x: number; y: number; r: number };
|
||||
z: number;
|
||||
tableRef: RefObject<HTMLDivElement | null>;
|
||||
onFront: () => void;
|
||||
register: (h: Handle | null) => void;
|
||||
}
|
||||
|
||||
function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardItemProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [lifted, setLifted] = useState(false);
|
||||
// Framer meldet nach einem Wurf trotzdem einen Tap; der soll die Karte nicht umdrehen
|
||||
const dragged = useRef(false);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const base = useMotionValue(spot.r);
|
||||
// Neigung folgt der Wurfgeschwindigkeit, geglättet über eine Feder
|
||||
const vx = useVelocity(x);
|
||||
const tilt = useSpring(useTransform(vx, [-1600, 0, 1600], [-10, 0, 10]), { stiffness: 300, damping: 30 });
|
||||
const rotate = useTransform(() => base.get() + (reduce ? 0 : tilt.get()));
|
||||
|
||||
const { w, h } = BOARD_SIZE[obj.kind];
|
||||
const canFlip = obj.kind === "project";
|
||||
const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={(el) => register(el ? { el, x, y, base, spotRotation: spot.r } : null)}
|
||||
drag
|
||||
dragConstraints={tableRef}
|
||||
dragElastic={0.18}
|
||||
dragTransition={{ power: 0.35, timeConstant: 320, bounceStiffness: 260, bounceDamping: 22 }}
|
||||
whileDrag={{ scale: 1.05 }}
|
||||
onPointerDown={() => {
|
||||
dragged.current = false;
|
||||
onFront();
|
||||
}}
|
||||
onDragStart={() => {
|
||||
dragged.current = true;
|
||||
setLifted(true);
|
||||
}}
|
||||
onDragEnd={() => setLifted(false)}
|
||||
onTap={canFlip ? () => !dragged.current && setFlipped((f) => !f) : undefined}
|
||||
onKeyDown={
|
||||
canFlip
|
||||
? (e) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
setFlipped((f) => !f);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
tabIndex={canFlip ? 0 : undefined}
|
||||
role={canFlip ? "button" : undefined}
|
||||
aria-pressed={canFlip ? flipped : undefined}
|
||||
aria-label={canFlip && obj.kind === "project" ? obj.title : undefined}
|
||||
className={`absolute cursor-grab touch-none select-none active:cursor-grabbing ${radius} transition-shadow duration-200 ease-out ${
|
||||
lifted ? "shadow-[14px_16px_0_rgb(var(--plakat-ink))]" : "shadow-[6px_6px_0_rgb(var(--plakat-ink))]"
|
||||
}`}
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
rotate,
|
||||
width: w,
|
||||
height: h,
|
||||
left: `min(${spot.x * 100}%, calc(100% - ${w}px))`,
|
||||
top: spot.y,
|
||||
zIndex: z,
|
||||
}}
|
||||
>
|
||||
<ObjectFace obj={obj} labels={labels} flipped={flipped} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user