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,163 @@
'use client';
/**
* Profil-Modal: der angemeldete Benutzer kann seinen Anzeigenamen
* (Username) aendern. E-Mail und Rolle sind nur lesbar dargestellt.
*
* Der Username ist optional und eindeutig; beim Speichern prueft der
* Server das Format und die Eindeutigkeit. Ein leerer Wert loescht
* den Username (das Badge faellt auf die E-Mail zurueck).
*
* Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem
* Speichern aktualisiert router.refresh() die Sidebar sofort.
*/
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui';
import { ModalShell } from '@/components/ui/ModalShell';
interface Profile {
id: string;
email: string;
username: string | null;
role: 'ADMIN' | 'USER';
}
export function ProfileModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const loadProfile = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/me/profile', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Profil konnte nicht geladen werden.');
}
const data = (await response.json()) as { user: Profile };
setProfile(data.user);
} catch (loadError) {
setError(
loadError instanceof Error
? loadError.message
: 'Profil konnte nicht geladen werden.',
);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadProfile();
}, [loadProfile]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSaving(true);
setError(null);
setSuccess(false);
const formData = new FormData(event.currentTarget);
const username = String(formData.get('username') ?? '').trim();
try {
const response = await fetch('/api/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username || null }),
});
if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Speichern fehlgeschlagen.');
return;
}
const data = (await response.json()) as { user: Profile };
setProfile(data.user);
setSuccess(true);
// Sidebar + Seite aktualisieren, damit der neue Name ueberall
// sofort sichtbar wird.
router.refresh();
} catch {
setError('Netzwerkfehler. Bitte erneut versuchen.');
} finally {
setIsSaving(false);
}
}
return (
<ModalShell
title="Profil"
ariaLabel="Profil bearbeiten"
onClose={onClose}
>
{isLoading ? (
<p className="py-4 text-sm text-slate-600 dark:text-slate-400">
Profil wird geladen …
</p>
) : !profile ? (
<ErrorMessage message={error ?? 'Profil konnte nicht geladen werden.'} />
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<ErrorMessage message={error} />
{success && (
<p className="rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
Profil wurde gespeichert.
</p>
)}
<div>
<Label htmlFor="profile-email">E-Mail</Label>
<Input
id="profile-email"
type="email"
value={profile.email}
readOnly
disabled
aria-readonly
/>
<p className="mt-1 text-xs text-slate-500">
Die E-Mail-Adresse kann nicht geaendert werden.
</p>
</div>
<div>
<Label htmlFor="profile-username">Benutzername</Label>
<Input
id="profile-username"
name="username"
type="text"
maxLength={32}
defaultValue={profile.username ?? ''}
placeholder="z. B. max_mustermann"
autoComplete="username"
/>
<p className="mt-1 text-xs text-slate-500">
2-32 Zeichen; Buchstaben, Ziffern, _ - . erlaubt. Leer lassen, um
den Namen zu entfernen (es wird dann die E-Mail angezeigt).
</p>
</div>
<div>
<Label>Rolle</Label>
<div className="pt-1">
<Badge variant={profile.role === 'ADMIN' ? 'accent' : 'neutral'}>
{profile.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
</Badge>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Speichern …' : 'Speichern'}
</Button>
</div>
</form>
)}
</ModalShell>
);
}