From 0fa10315b23426f76da4dd7dbe48951ec1573494 Mon Sep 17 00:00:00 2001 From: denshooter <44590296+denshooter@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:49:35 +0200 Subject: [PATCH] feat: plakat redesign phase 3 (windows on desktop, sheets on mobile) (#76) - 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 --- app/__tests__/pre-push-check.test.ts | 1 + app/_ui/HomePageServer.tsx | 32 +- app/components/Hero.tsx | 6 +- app/components/fenster/OpenWindowLink.tsx | 29 ++ app/components/fenster/WindowContent.tsx | 247 +++++++++++ app/components/fenster/WindowProvider.tsx | 276 +++++++++++++ app/components/fenster/getWindowLabels.ts | 55 +++ app/components/fenster/types.ts | 55 +++ app/components/tisch/Board.tsx | 40 +- app/components/tisch/Deck.tsx | 25 +- app/components/tisch/ObjectFace.tsx | 76 +--- app/components/tisch/TischSection.tsx | 2 + app/components/tisch/currentBook.ts | 24 ++ app/components/tisch/types.ts | 2 + app/components/tisch/windowFor.ts | 37 ++ messages/de.json | 72 +++- messages/en.json | 72 +++- package-lock.json | 472 +++++++++++++++++++++- package.json | 3 +- 19 files changed, 1413 insertions(+), 113 deletions(-) create mode 100644 app/components/fenster/OpenWindowLink.tsx create mode 100644 app/components/fenster/WindowContent.tsx create mode 100644 app/components/fenster/WindowProvider.tsx create mode 100644 app/components/fenster/getWindowLabels.ts create mode 100644 app/components/fenster/types.ts create mode 100644 app/components/tisch/currentBook.ts create mode 100644 app/components/tisch/windowFor.ts diff --git a/app/__tests__/pre-push-check.test.ts b/app/__tests__/pre-push-check.test.ts index f62d2cf..b0f75b6 100644 --- a/app/__tests__/pre-push-check.test.ts +++ b/app/__tests__/pre-push-check.test.ts @@ -138,6 +138,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.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] }, "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 bb9f4eb..adc141a 100644 --- a/app/_ui/HomePageServer.tsx +++ b/app/_ui/HomePageServer.tsx @@ -1,6 +1,8 @@ import Header from "../components/Header.server"; import Hero from "../components/Hero"; import TischSection from "../components/tisch/TischSection"; +import WindowProvider from "../components/fenster/WindowProvider"; +import { getWindowLabels } from "../components/fenster/getWindowLabels"; import ScrollFadeIn from "../components/ScrollFadeIn"; import Script from "next/script"; import { @@ -20,10 +22,11 @@ interface HomePageServerProps { export default async function HomePageServer({ locale }: HomePageServerProps) { // Parallel laden aller Translations (hero translations handled by Hero server component) - const [aboutT, contactT, footerT] = await Promise.all([ + const [aboutT, contactT, footerT, windowLabels] = await Promise.all([ getAboutTranslations(locale), getContactTranslations(locale), getFooterTranslations(locale), + getWindowLabels(locale), ]); return ( @@ -86,22 +89,23 @@ export default async function HomePageServer({ locale }: HomePageServerProps) { }} />
-
- - + +
+ + + + + + + + + +
- + - - - - - -
- - - + ); } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 6ec1911..2807c47 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,5 +1,6 @@ import { getTranslations } from "next-intl/server"; import KineticWordmark from "./KineticWordmark"; +import OpenWindowLink from "./fenster/OpenWindowLink"; interface HeroProps { locale: string; @@ -39,12 +40,13 @@ export default async function Hero({ locale }: HeroProps) { > {t("ctaWork")} - {t("ctaContact")} - + diff --git a/app/components/fenster/OpenWindowLink.tsx b/app/components/fenster/OpenWindowLink.tsx new file mode 100644 index 0000000..de14d5e --- /dev/null +++ b/app/components/fenster/OpenWindowLink.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useOpenWindow } from "./WindowProvider"; +import type { WindowRequest } from "./types"; + +interface OpenWindowLinkProps extends React.AnchorHTMLAttributes { + href: string; + request: WindowRequest; +} + +/** + * Ein normaler Link (funktioniert ohne JavaScript), der mit JavaScript stattdessen + * ein Fenster bzw. Sheet öffnet. + */ +export default function OpenWindowLink({ request, onClick, ...props }: OpenWindowLinkProps) { + const openWindow = useOpenWindow(); + return ( + { + onClick?.(e); + if (!openWindow || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return; + e.preventDefault(); + openWindow(request, e.currentTarget); + }} + /> + ); +} diff --git a/app/components/fenster/WindowContent.tsx b/app/components/fenster/WindowContent.tsx new file mode 100644 index 0000000..f71ac0f --- /dev/null +++ b/app/components/fenster/WindowContent.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook"; +import type { WindowLabels, WindowRequest } from "./types"; + +const EMAIL = "contact@dk0.dev"; + +const btn = + "inline-flex items-center gap-2 rounded-full bg-ink px-4 py-2.5 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"; +const ghost = + "inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-sm 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]"; + +/** Titelzeile eines Fensters, auch für die Ablage-Leiste. */ +export function windowTitle(req: WindowRequest, labels: WindowLabels): string { + switch (req.kind) { + case "project": + return req.project.year ? `${req.project.title} · ${req.project.year}` : req.project.title; + case "now": + return labels.now.title; + case "server": + return labels.server.title; + case "read": + return labels.read.title; + case "about": + return labels.about.title; + case "contact": + return labels.contact.title; + } +} + +/** Farbe des Punkts in der Ablage-Leiste */ +export function windowAccent(req: WindowRequest): string { + if (req.kind === "project") return req.project.color; + return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))" }[req.kind]; +} + +interface WindowContentProps { + req: WindowRequest; + labels: WindowLabels; + /** Schließt das Fenster, z. B. wenn ein Link auf der Seite weiterführt */ + onClose: () => void; +} + +export default function WindowContent({ req, labels, onClose }: WindowContentProps) { + switch (req.kind) { + case "project": { + const p = req.project; + return ( + <> +
+ {p.tags.slice(0, 4).join(" · ")} + + {p.title} + +
+

{p.description}

+
+ {p.year && {p.year}} + {p.tags.length > 0 && {p.tags.join(", ")}} +
+
+ + {labels.projectPage} + + {p.liveUrl && ( + + {labels.live} + + )} + {p.githubUrl && ( + + {labels.source} + + )} +
+ + ); + } + + case "now": + return ; + + case "server": + return ( + <> +

{labels.server.headline}

+
+ {labels.server.rows.map((r) => ( +
+
+ ))} +
+

{labels.server.note}

+ + ); + + case "read": + return ; + + case "about": + return ( + <> + {labels.about.paragraphs.map((text) => ( +

+ {text} +

+ ))} +
+ {labels.about.facts.map((f) => ( + + {f.value} + + ))} +
+ + ); + + case "contact": + return ; + } +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +// Abgabe der Masterarbeit; auf dem Client gerechnet, damit das Fenster nie einen veralteten Stand zeigt +const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00"); + +function NowBody({ labels }: { labels: WindowLabels }) { + const days = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000)); + return ( + <> +
+
+ {days} +
+

{labels.now.days}

+
+
+ {labels.now.rows.map((r) => ( + + {r.text} + + ))} +
+ + ); +} + +function ReadBody({ labels }: { labels: WindowLabels }) { + const [book, setBook] = useState(undefined); + useEffect(() => { + let alive = true; + loadCurrentBook().then((b) => alive && setBook(b)); + return () => { + alive = false; + }; + }, []); + const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0; + + return ( + <> + {book === undefined ? ( +