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>
);
}

120
components/ui/ui.tsx Normal file
View File

@@ -0,0 +1,120 @@
/** Gemeinsame UI-Bausteine (Forms, Buttons, Badges) im einheitlichen Look. */
/**
* Einheitliches Badge fuer Status/Rollen-Labels.
* variant: neutral (Standard), success (aktiv), warning (Hinweis),
* danger (Fehler), accent (hervorgehoben, z. B. eigene Rolle).
*/
export function Badge({
children,
variant = 'neutral',
}: {
children: React.ReactNode;
variant?: 'neutral' | 'success' | 'warning' | 'danger' | 'accent';
}) {
const styles = {
neutral:
'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
success:
'bg-slate-200 text-slate-800 ring-1 ring-slate-400 dark:bg-slate-600 dark:text-slate-100 dark:ring-slate-500',
warning:
'bg-slate-100 text-slate-700 ring-1 ring-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600',
danger:
'bg-slate-800 text-slate-100 dark:bg-slate-100 dark:text-slate-900',
accent:
'bg-slate-700 text-slate-50 dark:bg-slate-300 dark:text-slate-900',
}[variant];
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${styles}`}
>
{children}
</span>
);
}
export function Card({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={`rounded-lg border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900 ${className}`}
>
{children}
</div>
);
}
export function Button({
children,
type = 'submit',
variant = 'primary',
disabled,
onClick,
...rest
}: {
children: React.ReactNode;
type?: 'submit' | 'button';
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
onClick?: () => void;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick' | 'disabled'>) {
const styles = {
primary: 'bg-slate-800 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200',
secondary:
'bg-slate-200 text-slate-900 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
danger: 'bg-red-600 text-white hover:bg-red-700',
}[variant];
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
{...rest}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${styles}`}
>
{children}
</button>
);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
const { className = '', ...rest } = props;
return (
<input
{...rest}
className={`w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${className}`}
/>
);
}
export function Label({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
return (
<label
htmlFor={htmlFor}
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
{children}
</label>
);
}
export function ErrorMessage({ message }: { message: string | null }) {
if (!message) {
return null;
}
return (
<p
role="alert"
className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/40 dark:text-red-300"
>
{message}
</p>
);
}