Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
163
components/profile/ProfileModal.tsx
Normal file
163
components/profile/ProfileModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user