Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
'use client';
/**
* Gemeinsamer Modal-Rahmen fuer alle Dialoge der App.
*
* Schliessen moeglich via:
* - X-Button (immer, auch waehrend einer laufenden Aktion)
* - ESC-Taste (ausser waehrend isPending, um Abbrueche zu vermeiden)
* - Klick auf den dunklen Hintergrund (ausser waehrend isPending)
*
* Alle Modals der App MUESSEN diesen Rahmen verwenden, damit das
* Schliess-Verhalten einheitlich ist (DRY).
*/
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
/** Verfuegbare Modal-Groessen. */
export type ModalSize = 'md' | 'lg' | 'xl';
const SIZE_CLASSES: Record<ModalSize, string> = {
md: 'max-w-md',
lg: 'max-w-2xl',
xl: 'max-w-5xl',
};
export function ModalShell({
title,
ariaLabel,
isPending = false,
size = 'md',
onClose,
children,
}: {
title: string;
/** Accessibility-Beschriftung (faellt auf title zurueck). */
ariaLabel?: string;
/** Blockiert ESC/Backdrop-Schliessen waehrend einer laufenden Aktion. */
isPending?: boolean;
size?: ModalSize;
onClose: () => void;
children: React.ReactNode;
}) {
// ESC schliesst das Modal. Der Listener wird bei jedem Render von
// onClose/isPending neu registriert (guenstig und immer aktuell).
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && !isPending) {
onClose();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPending, onClose]);
// Merkt sich, ob der mousedown auf dem Backdrop (ausserhalb des
// Modal-Inhalts) begann. Nur dann ist ein click ein echtes
// "Klick ausserhalb". Grund: Bei einer Textauswahl, die IM Modal
// beginnt und ausserhalb endet, feuert der Browser das click-Event
// auf dem gemeinsamen Vorfahren beider Ziele - dem Backdrop. Ohne
// diese Pruefung wuerde das Modal beim Markieren von Text
// fälschlich geschlossen.
const mouseDownOnBackdropRef = useRef(false);
// Portal: Das Modal wird auf document.body gerendert, nicht innerhalb
// der aufrufenden Komponente. Grund: Wird ein Modal z. B. in der
// Sidebar (sticky, eigener Stacking-Kontext) gerendert, wuerde sein
// z-50 nur innerhalb der Sidebar gelten - Hauptinhalte daneben
// wuerden es überlappen. Auf body-Ebene gilt z-50 global.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
role="dialog"
aria-modal="true"
aria-label={ariaLabel ?? title}
onMouseDown={(event) => {
mouseDownOnBackdropRef.current =
event.target === event.currentTarget;
}}
onClick={(event) => {
if (
event.target === event.currentTarget &&
mouseDownOnBackdropRef.current &&
!isPending
) {
mouseDownOnBackdropRef.current = false;
onClose();
}
}}
>
<div
className={`w-full ${SIZE_CLASSES[size]} max-h-[90vh] overflow-y-auto rounded-lg border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900`}
>
<div className="mb-4 flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Schliessen"
className="rounded-md p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<XIcon />
</button>
</div>
{children}
</div>
</div>,
document.body,
);
}
/** X-Icon (2D, Outline-Stil) zum Schliessen des Modals. */
function XIcon() {
return (
<svg
width={18}
height={18}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
}