Files
denshooter 9e6ae866d0
CI / CD / test-build (push) Successful in 11m13s
CI / CD / deploy-dev (push) Successful in 2m25s
CI / CD / deploy-production (push) Has been skipped
feat: plakat redesign phase 4 (chat window, contact, footer, cleanup) (#77)
- AI chat moves into a window/sheet, opened from a new speech-bubble card on
  the desk; it now respects the chatbot consent and asks before sending
  anything to n8n (previously it ignored the consent)
- Contact as red poster section with big mail address and the form on a paper
  card; validation errors are finally shown inline (aria-invalid, role=alert)
- Footer as ink strip with dk0 wordmark, legal links and a button to reopen
  the privacy settings
- "About me" in the header opens the about window on the home page
- Remove the old About section and its parts (BentoChat, ActivityFeed,
  CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid,
  their tests, wrappers, loaders, types and texts
- scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives
  hobbies, tech stack categories and old content pages and deletes four
  unused message overrides after a full JSON backup; run only after the
  redesign is live on production

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:56 +02:00

211 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]", chat: "rounded-[22px_22px_22px_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>
);
}