From 731cd58ba892a4cec23c9156dea43b60f8fea241 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 15 Oct 2026 18:47:42 +0200 Subject: [PATCH] feat: plakat redesign phase 2 (desk with throwable objects, mobile card deck) - 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 --- app/__tests__/pre-push-check.test.ts | 1 + app/_ui/HomePageServer.tsx | 10 +- app/components/tisch/Board.tsx | 206 ++++++++++++++++++++++++++ app/components/tisch/Deck.tsx | 151 +++++++++++++++++++ app/components/tisch/ObjectFace.tsx | 193 ++++++++++++++++++++++++ app/components/tisch/TischSection.tsx | 104 +++++++++++++ app/components/tisch/types.ts | 100 +++++++++++++ messages/de.json | 36 +++++ messages/en.json | 36 +++++ tailwind.config.ts | 3 + 10 files changed, 833 insertions(+), 7 deletions(-) create mode 100644 app/components/tisch/Board.tsx create mode 100644 app/components/tisch/Deck.tsx create mode 100644 app/components/tisch/ObjectFace.tsx create mode 100644 app/components/tisch/TischSection.tsx create mode 100644 app/components/tisch/types.ts diff --git a/app/__tests__/pre-push-check.test.ts b/app/__tests__/pre-push-check.test.ts index 5c66e18..f62d2cf 100644 --- a/app/__tests__/pre-push-check.test.ts +++ b/app/__tests__/pre-push-check.test.ts @@ -137,6 +137,7 @@ const STATIC_KEYS: Record> = { }, consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] }, "home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] }, + "home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "sticker"] }, "home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] }, "home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] }, "home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] }, diff --git a/app/_ui/HomePageServer.tsx b/app/_ui/HomePageServer.tsx index 1fd5f67..bb9f4eb 100644 --- a/app/_ui/HomePageServer.tsx +++ b/app/_ui/HomePageServer.tsx @@ -1,16 +1,15 @@ import Header from "../components/Header.server"; import Hero from "../components/Hero"; +import TischSection from "../components/tisch/TischSection"; import ScrollFadeIn from "../components/ScrollFadeIn"; import Script from "next/script"; import { getAboutTranslations, - getProjectsTranslations, getContactTranslations, getFooterTranslations, } from "@/lib/translations-loader"; import { AboutClient, - ProjectsClient, ContactClient, FooterClient, } from "../components/ClientWrappers"; @@ -21,9 +20,8 @@ interface HomePageServerProps { export default async function HomePageServer({ locale }: HomePageServerProps) { // Parallel laden aller Translations (hero translations handled by Hero server component) - const [aboutT, projectsT, contactT, footerT] = await Promise.all([ + const [aboutT, contactT, footerT] = await Promise.all([ getAboutTranslations(locale), - getProjectsTranslations(locale), getContactTranslations(locale), getFooterTranslations(locale), ]); @@ -90,14 +88,12 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
+ - - - diff --git a/app/components/tisch/Board.tsx b/app/components/tisch/Board.tsx new file mode 100644 index 0000000..2992934 --- /dev/null +++ b/app/components/tisch/Board.tsx @@ -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; + y: MotionValue; + base: MotionValue; + 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(null); + const handles = useRef(new Map()); + 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 ( +
+
+

{labels.hintBoard}

+
+ + +
+
+ +
+ {objects.map((obj, i) => ( + bringToFront(obj.id)} + register={(h) => { + if (h) handles.current.set(obj.id, h); + else handles.current.delete(obj.id); + }} + /> + ))} +
+
+ ); +} + +interface BoardItemProps { + obj: TischObject; + labels: TischLabels; + spot: { x: number; y: number; r: number }; + z: number; + tableRef: RefObject; + 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 ( + 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, + }} + > + + + ); +} diff --git a/app/components/tisch/Deck.tsx b/app/components/tisch/Deck.tsx new file mode 100644 index 0000000..b822e0a --- /dev/null +++ b/app/components/tisch/Deck.tsx @@ -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({ 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 ( +
+

{labels.hintDeck}

+
+ + {visible + .map((idx, depth) => ( + + )) + .reverse()} + +
+
+ + ← + + + {pad(order[0] + 1)} / {pad(objects.length)} + + next(1)}> + → + +
+
+ ); +} + +function RoundButton({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} + +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 ( + + { + 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} + > + + + + ); +} diff --git a/app/components/tisch/ObjectFace.tsx b/app/components/tisch/ObjectFace.tsx new file mode 100644 index 0000000..7e7e29c --- /dev/null +++ b/app/components/tisch/ObjectFace.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import type { TischLabels, TischObject } from "./types"; + +interface ObjectFaceProps { + obj: TischObject; + labels: TischLabels; + flipped?: boolean; +} + +const pad = (n: number) => String(n).padStart(2, "0"); + +// Rand und Rundung, die alle Objekte teilen +const edge = "h-full w-full overflow-hidden rounded-2xl border-2 border-ink"; + +/** Zeichnet ein Objekt. Größe und Position bestimmt der Container (Tisch oder Stapel). */ +export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceProps) { + switch (obj.kind) { + case "project": + return ( +
+
+
+
+ ({pad(obj.index + 1)}) + {obj.year} +
+

+ {obj.title} +

+
+
{obj.tags.slice(0, 3).join(" · ")}
+
{labels.flip} ↻
+
+
+
+
+ {obj.title} + {obj.year} +
+

{obj.description}

+ e.stopPropagation()} + > + {labels.details} + +
+
+
+ ); + + case "ticket": + return ( +
+
+ {labels.ticketTitle} +
+
+ {obj.daysLeft} +
+
{labels.ticketDays}
+
+ {labels.ticketJob} +
+
+ {labels.ticketStub} +
+
+ ); + + case "server": + return ( +
+

{labels.serverTitle}

+ {labels.servers.map((s) => ( +
+
+ ))} +

{labels.serverNote}

+
+ ); + + case "book": + return ; + + case "photo": + return ( +
+
+ Dennis Konkol +
+
{labels.photoCaption}
+
+ ); + + case "sticker": + return ( +
+ {labels.sticker} +
+ ); + } +} + +interface CurrentBook { + title: string; + authors: string[]; + image: string | null; + progress: number; +} + +// Tisch und Stapel zeigen das Buch beide; so wird es nur einmal geladen +let bookRequest: Promise | null = null; +function loadCurrentBook(): Promise { + if (!bookRequest) { + bookRequest = fetch("/api/n8n/hardcover/currently-reading") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const list = data?.currentlyReading; + const first = Array.isArray(list) ? list[0] : list; + return first ?? null; + }) + .catch(() => null); + } + return bookRequest; +} + +function BookFace({ href, labels }: { href: string; labels: TischLabels }) { + const [book, setBook] = useState(undefined); + + useEffect(() => { + let alive = true; + loadCurrentBook().then((b) => { + if (alive) setBook(b); + }); + return () => { + alive = false; + }; + }, []); + + const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0; + + return ( +
+ {book?.image && ( + + )} +
+ {labels.bookLabel} + {book === undefined ? ( +