"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(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([]); const [sheet, setSheet] = useState<{ req: WindowRequest; trigger: HTMLElement | null } | null>(null); const [sheetOpen, setSheetOpen] = useState(false); const layerRef = useRef(null); const zTop = useRef(10); const opened = useRef(0); const open = useCallback((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 ( {children} {/* Desktop: Fenster schweben über der Seite, die Seite bleibt bedienbar */}
{wins .filter((w) => !w.minimized) .map((w) => ( close(w.id)} onMinimize={() => minimize(w.id)} onFocus={() => focusWin(w.id)} /> ))}
{wins.length > 0 && ( {wins.map((w) => ( ))} )} {/* Handy: ein Sheet von unten, nach unten ziehen schließt */} { setSheetOpen(o); if (!o && sheet?.trigger?.isConnected) requestAnimationFrame(() => sheet.trigger?.focus({ preventScroll: true })); }} > {sheet && ( <>
{windowTitle(sheet.req, labels)} ×
setSheetOpen(false)} />
)}
); } interface WindowProps { win: WinState; labels: WindowLabels; layerRef: RefObject; 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(null); const titleId = useId(); useEffect(() => { closeRef.current?.focus({ preventScroll: true }); }, []); return ( 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` }} >
{ if ((e.target as HTMLElement).closest("button")) return; controls.start(e); }} > {windowTitle(win.req, labels)} – ×
); } function HeadButton({ label, onClick, buttonRef, children, }: { label: string; onClick: () => void; buttonRef?: RefObject; children: React.ReactNode; }) { return ( ); }