279 lines
9.0 KiB
TypeScript
279 lines
9.0 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
export default function AdminSystemSettings() {
|
||
const [apiKey, setApiKey] = useState("");
|
||
const [provider, setProvider] = useState("osm");
|
||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||
const [publicAccessEnabled, setPublicAccessEnabled] = useState(true);
|
||
const [emailVerificationRequired, setEmailVerificationRequired] = useState(true);
|
||
const [appName, setAppName] = useState("Vereinskalender");
|
||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
||
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
||
const [status, setStatus] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const load = async () => {
|
||
try {
|
||
const [placesResponse, appNameResponse] = await Promise.all([
|
||
fetch("/api/settings/system"),
|
||
fetch("/api/settings/app-name")
|
||
]);
|
||
if (!placesResponse.ok) {
|
||
throw new Error("Einstellungen konnten nicht geladen werden.");
|
||
}
|
||
const payload = await placesResponse.json();
|
||
setApiKey(payload.apiKey || "");
|
||
setProvider(payload.provider || "osm");
|
||
setRegistrationEnabled(payload.registrationEnabled !== false);
|
||
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
|
||
setEmailVerificationRequired(payload.emailVerificationRequired !== false);
|
||
if (appNameResponse.ok) {
|
||
const appPayload = await appNameResponse.json();
|
||
setAppName(appPayload.name || "Vereinskalender");
|
||
}
|
||
} catch (err) {
|
||
setError((err as Error).message);
|
||
}
|
||
};
|
||
|
||
const loadLogoStatus = async () => {
|
||
try {
|
||
const response = await fetch("/api/branding/logo", {
|
||
method: "HEAD",
|
||
cache: "no-store"
|
||
});
|
||
setHasLogo(response.ok);
|
||
} catch {
|
||
setHasLogo(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
loadLogoStatus();
|
||
}, []);
|
||
|
||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
setStatus(null);
|
||
setError(null);
|
||
|
||
const [settingsResponse, appNameResponse] = await Promise.all([
|
||
fetch("/api/settings/system", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
apiKey,
|
||
provider,
|
||
registrationEnabled,
|
||
publicAccessEnabled,
|
||
emailVerificationRequired
|
||
})
|
||
}),
|
||
fetch("/api/settings/app-name", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: appName })
|
||
})
|
||
]);
|
||
|
||
if (!settingsResponse.ok) {
|
||
const data = await settingsResponse.json();
|
||
setError(data.error || "Speichern fehlgeschlagen.");
|
||
return;
|
||
}
|
||
|
||
if (!appNameResponse.ok) {
|
||
const data = await appNameResponse.json();
|
||
setError(data.error || "App-Name konnte nicht gespeichert werden.");
|
||
return;
|
||
}
|
||
|
||
setStatus("Gespeichert.");
|
||
};
|
||
|
||
const onLogoUpload = async (event: React.FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
setStatus(null);
|
||
setError(null);
|
||
|
||
if (!logoFile) {
|
||
setError("Bitte ein Logo auswählen.");
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append("file", logoFile);
|
||
|
||
const response = await fetch("/api/settings/logo", {
|
||
method: "POST",
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const data = await response.json();
|
||
setError(data.error || "Logo-Upload fehlgeschlagen.");
|
||
return;
|
||
}
|
||
|
||
setLogoFile(null);
|
||
setLogoVersion(Date.now());
|
||
setHasLogo(true);
|
||
setStatus("Logo gespeichert.");
|
||
};
|
||
|
||
const onLogoRemove = async () => {
|
||
setStatus(null);
|
||
setError(null);
|
||
const response = await fetch("/api/settings/logo", { method: "DELETE" });
|
||
if (!response.ok) {
|
||
const data = await response.json();
|
||
setError(data.error || "Logo konnte nicht gelöscht werden.");
|
||
return;
|
||
}
|
||
setHasLogo(false);
|
||
setLogoVersion(Date.now());
|
||
setStatus("Logo entfernt.");
|
||
};
|
||
|
||
return (
|
||
<section className="card space-y-4">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">System</p>
|
||
<h1 className="text-2xl font-semibold">API Einstellungen</h1>
|
||
</div>
|
||
<form onSubmit={onLogoUpload} className="space-y-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-700">App-Logo</p>
|
||
<p className="text-xs text-slate-500">
|
||
PNG, JPG, WEBP oder SVG. Empfohlen: 240×64 px.
|
||
</p>
|
||
</div>
|
||
{hasLogo ? (
|
||
<div className="flex items-center gap-3">
|
||
<img
|
||
src={`/api/branding/logo?ts=${logoVersion}`}
|
||
alt="Aktuelles Logo"
|
||
className="h-10 max-w-[180px] rounded-md border border-slate-200 bg-white object-contain px-2"
|
||
onError={() => setHasLogo(false)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={onLogoRemove}
|
||
className="btn-ghost"
|
||
>
|
||
Logo entfernen
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-slate-500">Kein Logo hinterlegt.</p>
|
||
)}
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<label className="btn-ghost cursor-pointer">
|
||
Datei auswählen
|
||
<input
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||
onChange={(event) =>
|
||
setLogoFile(event.currentTarget.files?.[0] || null)
|
||
}
|
||
className="sr-only"
|
||
/>
|
||
</label>
|
||
<span className="text-sm text-slate-600">
|
||
{logoFile ? logoFile.name : "Keine Datei ausgewählt"}
|
||
</span>
|
||
<button type="submit" className="btn-accent">
|
||
Logo hochladen
|
||
</button>
|
||
</div>
|
||
</form>
|
||
<form onSubmit={onSubmit} className="space-y-3">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-slate-700">
|
||
App-Name
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={appName}
|
||
onChange={(event) => setAppName(event.target.value)}
|
||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||
placeholder="Vereinskalender"
|
||
required
|
||
maxLength={60}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-slate-700">
|
||
Ortsanbieter
|
||
</label>
|
||
<select
|
||
value={provider}
|
||
onChange={(event) => setProvider(event.target.value)}
|
||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||
>
|
||
<option value="osm">OpenStreetMap (Nominatim)</option>
|
||
<option value="google">Google Places</option>
|
||
</select>
|
||
</div>
|
||
{provider === "google" && (
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-slate-700">
|
||
Google Places API Key
|
||
</label>
|
||
<input
|
||
type="password"
|
||
value={apiKey}
|
||
onChange={(event) => setApiKey(event.target.value)}
|
||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||
placeholder="AIza..."
|
||
required
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 text-sm text-slate-700">
|
||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||
Zugriff
|
||
</p>
|
||
<div className="mt-3 space-y-3">
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={publicAccessEnabled}
|
||
onChange={(event) => setPublicAccessEnabled(event.target.checked)}
|
||
/>
|
||
Öffentlicher Zugriff erlauben
|
||
</label>
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={registrationEnabled}
|
||
onChange={(event) => setRegistrationEnabled(event.target.checked)}
|
||
/>
|
||
Registrierung erlauben
|
||
</label>
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={emailVerificationRequired}
|
||
onChange={(event) =>
|
||
setEmailVerificationRequired(event.target.checked)
|
||
}
|
||
/>
|
||
E-Mail-Verifizierung erforderlich
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<button type="submit" className="btn-accent">
|
||
Speichern
|
||
</button>
|
||
</form>
|
||
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||
</section>
|
||
);
|
||
}
|