f0e257ad19
- Tapping an object on the desk opens a window (desktop) or a bottom sheet (mobile) instead of flipping: projects, now, servers, reading, about, contact - Desktop windows in poster style (2px ink rule, hard offset shadow), open from the tapped object, thrown by the title bar with momentum and elastic viewport edges, stack by focus, minimize into a tray, Escape closes the top window, focus moves to close and returns to the trigger - Mobile sheets via vaul 1.1.2: drag down to dismiss, overlay, focus trap - Project window shows live/source links from Directus when present - Hero "Write me" opens the contact window; still a plain #contact link without JavaScript - Shared current-book loader for desk, deck and reading window - Texts in de/en, i18n key check extended Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
"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 { useOpenWindow } from "../fenster/WindowProvider";
|
|
import { labelFor, windowFor } from "./windowFor";
|
|
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 [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 openWindow = useOpenWindow();
|
|
const elRef = useRef<HTMLDivElement | null>(null);
|
|
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
|
|
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) => {
|
|
elRef.current = 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={() => !dragged.current && openIt()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === " ") {
|
|
e.preventDefault();
|
|
openIt();
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-haspopup="dialog"
|
|
aria-label={labelFor(obj, labels)}
|
|
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} />
|
|
</motion.div>
|
|
);
|
|
}
|