0fa10315b2
- 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>
163 lines
5.7 KiB
TypeScript
163 lines
5.7 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 { useOpenWindow } from "../fenster/WindowProvider";
|
|
import { labelFor, windowFor } from "./windowFor";
|
|
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 dragged = useRef(false);
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
const openWindow = useOpenWindow();
|
|
const openIt = () => openWindow?.(windowFor(obj), cardRef.current);
|
|
const x = useMotionValue(0);
|
|
const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
|
|
const isTop = depth === 0;
|
|
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
|
|
ref={cardRef}
|
|
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 ? () => !dragged.current && openIt() : undefined}
|
|
onKeyDown={(e) => {
|
|
if (isTop && e.key === " ") {
|
|
e.preventDefault();
|
|
openIt();
|
|
}
|
|
}}
|
|
tabIndex={isTop ? 0 : -1}
|
|
role="button"
|
|
aria-haspopup="dialog"
|
|
aria-label={labelFor(obj, labels)}
|
|
aria-hidden={!isTop}
|
|
>
|
|
<ObjectFace obj={obj} labels={labels} />
|
|
</motion.div>
|
|
</motion.div>
|
|
);
|
|
}
|