diff --git a/src/app/components.css b/src/app/components.css index 8903897..c8f6f67 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -69,6 +69,24 @@ opacity: 0.85; } + /** + * Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge. + * + * Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal + * was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter + * durch. backdrop-filter hilft hier nicht: liegt das Element in einem + * Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die + * Weichzeichnung wirkungslos. + */ + .popover { + background: rgba(9, 11, 24, 0.97); + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 0.9rem; + box-shadow: + 0 1px 0 0 rgba(255, 255, 255, 0.07) inset, + 0 28px 70px -24px rgba(0, 0, 0, 0.95); + } + /* ── Text & Akzente ───────────────────────────────────────── */ .text-gradient { diff --git a/src/components/StatusSelect.tsx b/src/components/StatusSelect.tsx index a5af657..db591fd 100644 --- a/src/components/StatusSelect.tsx +++ b/src/components/StatusSelect.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Check, ChevronDown, Loader2 } from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; +import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status"; import { cn } from "@/lib/utils"; @@ -22,6 +23,7 @@ export function StatusSelect({ onChanged, }: StatusSelectProps) { const router = useRouter(); + const triggerRef = useRef(null); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(status); const [pending, startTransition] = useTransition(); @@ -54,10 +56,12 @@ export function StatusSelect({ } return ( -
+ <> - {open ? ( - <> - - ); - })} -
- - ) : null} - + setOpen(false)} + role="listbox" + width={264} + > + {STATUS_ORDER.map((value) => { + const option = STATUS_META[value]; + const active = value === current; + return ( + + ); + })} + + ); } diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 0a1ceeb..d0c4066 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { @@ -12,6 +12,7 @@ import { Sparkles, UserRound, } from "lucide-react"; +import { Popover } from "@/components/ui/Popover"; import type { SessionUser } from "@/lib/auth/session"; import { cn } from "@/lib/utils"; @@ -85,23 +86,7 @@ function UserMenu({ user }: { user: SessionUser }) { const router = useRouter(); const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - if (!open) return; - function onPointerDown(event: MouseEvent) { - if (!containerRef.current?.contains(event.target as Node)) setOpen(false); - } - function onKey(event: KeyboardEvent) { - if (event.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", onPointerDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onPointerDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); + const triggerRef = useRef(null); async function logout() { setBusy(true); @@ -115,8 +100,9 @@ function UserMenu({ user }: { user: SessionUser }) { const initials = label.slice(0, 2).toUpperCase(); return ( -
+ <> - {open ? ( -
-
-

{label}

-

- @{user.username} - {user.role === "ADMIN" ? " · Administrator" : ""} -

-
+ setOpen(false)} width={240}> +
+

{label}

+

+ @{user.username} + {user.role === "ADMIN" ? " · Administrator" : ""} +

+
+ setOpen(false)} + className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" + > + + Mein Konto + + + {user.role === "ADMIN" ? ( setOpen(false)} - className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" + className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" > - - Mein Konto + + Admin-Einstellungen + ) : null} - {user.role === "ADMIN" ? ( - setOpen(false)} - className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white" - > - - Admin-Einstellungen - - ) : null} - - -
- ) : null} -
+ + + ); } diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 89a1bc5..8877c14 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -14,6 +15,14 @@ interface ModalProps { width?: "md" | "lg" | "xl"; } +/** + * Dialog per Portal am body. + * + * Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten + * heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen + * Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am + * Fenster. + */ export function Modal({ open, title, @@ -23,6 +32,10 @@ export function Modal({ footer, width = "lg", }: ModalProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + useEffect(() => { if (!open) return; const onKey = (event: KeyboardEvent) => { @@ -37,22 +50,22 @@ export function Modal({ }; }, [open, onClose]); - if (!open) return null; + if (!mounted || !open) return null; - return ( -
+ return createPortal( +
-
+ , + document.body, ); } diff --git a/src/components/ui/Popover.tsx b/src/components/ui/Popover.tsx new file mode 100644 index 0000000..243597a --- /dev/null +++ b/src/components/ui/Popover.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { cn } from "@/lib/utils"; + +/** + * Schwebendes Menue, das per Portal am body haengt. + * + * Warum nicht einfach ein absolut positioniertes Kind? + * + * 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den + * Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten. + * 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern + * ein transform. Beides erzeugt einen Stacking-Context und einen neuen + * Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und + * selbst position: fixed bezieht sich auf die Karte statt auf das + * Fenster. + * + * Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement + * berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt. + */ +export function Popover({ + anchorRef, + open, + onClose, + children, + align = "end", + width = 240, + className, + labelledBy, + role = "menu", +}: { + anchorRef: React.RefObject; + open: boolean; + onClose: () => void; + children: React.ReactNode; + /** "end" richtet die rechte Kante am Anker aus. */ + align?: "start" | "end"; + width?: number; + className?: string; + labelledBy?: string; + role?: "menu" | "listbox" | "dialog"; +}) { + const panelRef = useRef(null); + const [mounted, setMounted] = useState(false); + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => setMounted(true), []); + + const place = useCallback(() => { + const anchor = anchorRef.current; + if (!anchor) return; + + const rect = anchor.getBoundingClientRect(); + const margin = 8; + const gap = 6; + + let left = align === "end" ? rect.right - width : rect.left; + left = Math.min(Math.max(margin, left), window.innerWidth - width - margin); + + const panelHeight = panelRef.current?.offsetHeight ?? 0; + let top = rect.bottom + gap; + + // Nach oben klappen, wenn unten kein Platz ist, oben aber schon. + const fitsBelow = top + panelHeight <= window.innerHeight - margin; + const fitsAbove = rect.top - gap - panelHeight >= margin; + if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight; + + setPosition({ top, left }); + }, [anchorRef, align, width]); + + // Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim + // Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem + // nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt. + useEffect(() => { + if (!open) { + setPosition(null); + return; + } + place(); + }, [open, place]); + + useEffect(() => { + if (!open) return; + + // capture: true, damit auch das Scrollen innerer Container ankommt. + const onScroll = () => place(); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onScroll); + + function onPointerDown(event: MouseEvent) { + const target = event.target as Node; + if (panelRef.current?.contains(target)) return; + if (anchorRef.current?.contains(target)) return; + onClose(); + } + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKey); + + return () => { + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onScroll); + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKey); + }; + }, [open, place, onClose, anchorRef]); + + if (!mounted || !open) return null; + + return createPortal( +
+ {children} +
, + document.body, + ); +}