Files
portfolio/app/components/fenster/WindowProvider.tsx
T
denshooter 08e856120b feat: plakat redesign phase 3 (windows on desktop, sheets on mobile)
- 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>
2026-10-15 18:56:48 +02:00

277 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion, useDragControls } from "framer-motion";
import { Drawer } from "vaul";
import WindowContent, { windowAccent, windowTitle } from "./WindowContent";
import { windowId, type WindowLabels, type WindowRequest } from "./types";
type OpenWindow = (req: WindowRequest, trigger?: HTMLElement | null) => void;
const WindowContext = createContext<OpenWindow | null>(null);
/** Öffnet ein Fenster (Desktop) oder Sheet (Handy). Ohne Provider passiert nichts. */
export function useOpenWindow(): OpenWindow | null {
return useContext(WindowContext);
}
interface WinState {
id: string;
req: WindowRequest;
x: number;
y: number;
z: number;
minimized: boolean;
/** Punkt, von dem aus das Fenster aufgeht (relativ zum Fenster) */
origin: { x: number; y: number };
trigger: HTMLElement | null;
}
const WIN_WIDTH = 440;
export default function WindowProvider({ labels, children }: { labels: WindowLabels; children: React.ReactNode }) {
const [wins, setWins] = useState<WinState[]>([]);
const [sheet, setSheet] = useState<{ req: WindowRequest; trigger: HTMLElement | null } | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const layerRef = useRef<HTMLDivElement>(null);
const zTop = useRef(10);
const opened = useRef(0);
const open = useCallback<OpenWindow>((req, trigger = null) => {
const desktop = window.matchMedia?.("(min-width: 768px)")?.matches ?? true;
if (!desktop) {
setSheet({ req, trigger });
setSheetOpen(true);
return;
}
const id = windowId(req);
setWins((prev) => {
const existing = prev.find((w) => w.id === id);
if (existing) {
return prev.map((w) => (w.id === id ? { ...w, minimized: false, z: ++zTop.current } : w));
}
const W = window.innerWidth;
const H = window.innerHeight;
const width = Math.min(WIN_WIDTH, W - 32);
const step = opened.current++ % 5;
const x = Math.max(16, Math.min(W - width - 16, W / 2 - width / 2 + step * 34 - 60));
const y = Math.max(76, Math.min(H - 420, H * 0.16 + step * 30));
const r = trigger?.getBoundingClientRect();
const origin = r ? { x: r.left + r.width / 2 - x, y: r.top + r.height / 2 - y } : { x: width / 2, y: 0 };
return [...prev, { id, req, x, y, z: ++zTop.current, minimized: false, origin, trigger }];
});
}, []);
const close = useCallback((id: string) => {
setWins((prev) => {
const w = prev.find((x) => x.id === id);
// Fokus zurück dorthin, wo das Fenster herkam
if (w?.trigger?.isConnected) requestAnimationFrame(() => w.trigger?.focus({ preventScroll: true }));
return prev.filter((x) => x.id !== id);
});
}, []);
const minimize = useCallback((id: string) => {
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
}, []);
const focusWin = useCallback((id: string) => {
setWins((prev) => {
const top = prev.reduce((a, b) => (b.z > a.z ? b : a), prev[0]);
// Schon vorn und sichtbar: nichts zu tun (ein abgelegtes Fenster muss trotzdem zurück)
if (top?.id === id && !top.minimized) return prev;
return prev.map((w) => (w.id === id ? { ...w, z: ++zTop.current, minimized: false } : w));
});
}, []);
// Escape schließt das oberste offene Fenster
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const visible = wins.filter((w) => !w.minimized);
if (!visible.length) return;
const top = visible.reduce((a, b) => (b.z > a.z ? b : a));
close(top.id);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [wins, close]);
return (
<WindowContext.Provider value={open}>
{children}
{/* Desktop: Fenster schweben über der Seite, die Seite bleibt bedienbar */}
<div ref={layerRef} className="pointer-events-none fixed inset-0 z-[60]" aria-live="off">
<AnimatePresence>
{wins
.filter((w) => !w.minimized)
.map((w) => (
<Window
key={w.id}
win={w}
labels={labels}
layerRef={layerRef}
onClose={() => close(w.id)}
onMinimize={() => minimize(w.id)}
onFocus={() => focusWin(w.id)}
/>
))}
</AnimatePresence>
</div>
<AnimatePresence>
{wins.length > 0 && (
<motion.nav
aria-label={labels.openWindows}
className="fixed bottom-[18px] left-1/2 z-[61] hidden max-w-[calc(100vw-32px)] gap-1.5 overflow-x-auto rounded-full bg-ink p-1.5 md:flex"
style={{ x: "-50%" }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
>
{wins.map((w) => (
<button
key={w.id}
type="button"
onClick={() => focusWin(w.id)}
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full px-3.5 py-2 text-[13px] font-medium transition-[background-color,transform] duration-150 ease-out hover:bg-paper/15 active:scale-[0.97] ${
w.minimized ? "text-paper/60" : "text-paper"
}`}
>
<span className="h-2 w-2 rounded-full border border-paper/40" style={{ background: windowAccent(w.req) }} aria-hidden="true" />
{windowTitle(w.req, labels).split(" · ")[0]}
</button>
))}
</motion.nav>
)}
</AnimatePresence>
{/* Handy: ein Sheet von unten, nach unten ziehen schließt */}
<Drawer.Root
open={sheetOpen}
onOpenChange={(o) => {
setSheetOpen(o);
if (!o && sheet?.trigger?.isConnected) requestAnimationFrame(() => sheet.trigger?.focus({ preventScroll: true }));
}}
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-[70] bg-ink/40" />
<Drawer.Content
className="fixed inset-x-0 bottom-0 z-[71] flex max-h-[88svh] flex-col rounded-t-[22px] border-2 border-b-0 border-ink bg-paper text-ink outline-none"
aria-describedby={undefined}
>
<Drawer.Handle className="!mt-2.5 !h-[5px] !w-11 !bg-ink/30" />
{sheet && (
<>
<div className="flex items-center gap-3 border-b-2 border-ink px-5 pb-3 pt-2">
<Drawer.Title className="flex-1 truncate font-mono text-[13px] font-normal">{windowTitle(sheet.req, labels)}</Drawer.Title>
<Drawer.Close
className="grid h-8 w-8 place-items-center rounded-full border-2 border-ink text-[15px] font-semibold transition-transform duration-150 ease-out active:scale-95"
aria-label={labels.close}
>
×
</Drawer.Close>
</div>
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5 pb-[calc(20px+env(safe-area-inset-bottom,0px))]">
<WindowContent req={sheet.req} labels={labels} onClose={() => setSheetOpen(false)} />
</div>
</>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</WindowContext.Provider>
);
}
interface WindowProps {
win: WinState;
labels: WindowLabels;
layerRef: RefObject<HTMLDivElement | null>;
onClose: () => void;
onMinimize: () => void;
onFocus: () => void;
}
function Window({ win, labels, layerRef, onClose, onMinimize, onFocus }: WindowProps) {
const controls = useDragControls();
const [lifted, setLifted] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
useEffect(() => {
closeRef.current?.focus({ preventScroll: true });
}, []);
return (
<motion.div
role="dialog"
aria-labelledby={titleId}
drag
dragListener={false}
dragControls={controls}
dragConstraints={layerRef}
dragElastic={0.15}
dragTransition={{ power: 0.3, timeConstant: 300, bounceStiffness: 260, bounceDamping: 24 }}
onDragStart={() => setLifted(true)}
onDragEnd={() => setLifted(false)}
onPointerDown={onFocus}
initial={{ opacity: 0, scale: 0.86 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className={`pointer-events-auto fixed flex max-h-[calc(100vh-120px)] w-[min(440px,calc(100vw-32px))] flex-col rounded-[18px] border-2 border-ink bg-paper text-ink transition-shadow duration-200 ease-out ${
lifted ? "shadow-[16px_18px_0_rgb(var(--plakat-ink))]" : "shadow-[8px_8px_0_rgb(var(--plakat-ink))]"
}`}
style={{ left: win.x, top: win.y, zIndex: win.z, transformOrigin: `${win.origin.x}px ${win.origin.y}px` }}
>
<div
className="flex cursor-grab touch-none select-none items-center gap-2.5 border-b-2 border-ink py-2.5 pl-4 pr-2.5 active:cursor-grabbing"
onPointerDown={(e) => {
if ((e.target as HTMLElement).closest("button")) return;
controls.start(e);
}}
>
<span id={titleId} className="flex-1 truncate font-mono text-[13px]">
{windowTitle(win.req, labels)}
</span>
<HeadButton label={labels.minimize} onClick={onMinimize}>
–
</HeadButton>
<HeadButton label={labels.close} onClick={onClose} buttonRef={closeRef}>
×
</HeadButton>
</div>
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5">
<WindowContent req={win.req} labels={labels} onClose={onClose} />
</div>
</motion.div>
);
}
function HeadButton({
label,
onClick,
buttonRef,
children,
}: {
label: string;
onClick: () => void;
buttonRef?: RefObject<HTMLButtonElement | null>;
children: React.ReactNode;
}) {
return (
<button
ref={buttonRef}
type="button"
aria-label={label}
onClick={onClick}
className="grid h-[30px] w-[30px] place-items-center rounded-full border-2 border-ink bg-paper text-[15px] font-semibold transition-[background-color,transform] duration-150 ease-out hover:bg-signal active:scale-[0.94]"
>
<span aria-hidden="true">{children}</span>
</button>
);
}