feat: Einstellungen, Verwaltung und öffentliches Profil
Nach einem Passwortwechsel fliegen alle anderen Geräte raus; ein gesperrtes Konto wird sofort abgemeldet. Die letzte Administration lässt sich nicht entmachten, und sich selbst kann niemand sperren. Das öffentliche Profil zeigt Zahlen und zuletzt Gesehenes — keine Notizen, keine Watchlist, keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Es braucht die Freigabe des Nutzers und die des Betreibers.
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
changePasswordAction,
|
||||
createApiKeyAction,
|
||||
deleteChannelAction,
|
||||
importDataAction,
|
||||
revokeApiKeyAction,
|
||||
runSyncAction,
|
||||
saveChannelAction,
|
||||
setInstanceNameAction,
|
||||
setPublicProfilesAction,
|
||||
setRegistrationAction,
|
||||
setUserActiveAction,
|
||||
setUserRoleAction,
|
||||
testChannelAction,
|
||||
toggleChannelAction,
|
||||
updateProfileAction,
|
||||
} from "@/app/actions/settings";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import { formatDate } from "@/lib/labels";
|
||||
|
||||
/** Wiederverwendete Bausteine der Einstellungsseiten. */
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { id: string; label: string; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
{label}
|
||||
</label>
|
||||
<input id={id} name={id} className="fl-input" {...props} />
|
||||
{hint && <p className="text-xs text-[var(--fl-faint)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"mt-0.5 h-5 w-9 shrink-0 rounded-full p-0.5 transition-colors",
|
||||
checked ? "" : "bg-[var(--fl-surface-2)] ring-1 ring-[var(--fl-border-strong)]",
|
||||
)}
|
||||
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"block h-4 w-4 rounded-full bg-[var(--fl-text)] transition-transform",
|
||||
checked && "translate-x-4",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
{hint && <span className="block text-xs text-[var(--fl-faint)]">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProfileForm({
|
||||
user,
|
||||
publicProfilesEnabled,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
region: string;
|
||||
publicProfile: boolean;
|
||||
profileSlug: string | null;
|
||||
notifyNewEpisode: boolean;
|
||||
notifyRelease: boolean;
|
||||
notifyAvailability: boolean;
|
||||
};
|
||||
publicProfilesEnabled: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [state, setState] = useState(user);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={() =>
|
||||
startTransition(async () => {
|
||||
report(
|
||||
await updateProfileAction({
|
||||
name: state.name,
|
||||
region: state.region,
|
||||
publicProfile: state.publicProfile,
|
||||
profileSlug: state.profileSlug || null,
|
||||
notifyNewEpisode: state.notifyNewEpisode,
|
||||
notifyRelease: state.notifyRelease,
|
||||
notifyAvailability: state.notifyAvailability,
|
||||
}),
|
||||
);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="space-y-5"
|
||||
>
|
||||
<Field
|
||||
id="name"
|
||||
label="Anzeigename"
|
||||
value={state.name}
|
||||
onChange={(event) => setState({ ...state, name: event.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-[var(--fl-muted)]">E-Mail-Adresse</label>
|
||||
<p className="fl-input text-[var(--fl-faint)]">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="region"
|
||||
label="Region"
|
||||
value={state.region}
|
||||
maxLength={2}
|
||||
onChange={(event) => setState({ ...state, region: event.target.value.toUpperCase() })}
|
||||
hint="Zwei Buchstaben nach ISO 3166 — bestimmt, welche Streamingdienste angezeigt werden (DE, AT, CH …)."
|
||||
/>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||
Wovon soll Flimmer dir berichten?
|
||||
</legend>
|
||||
<Toggle
|
||||
checked={state.notifyNewEpisode}
|
||||
onChange={(value) => setState({ ...state, notifyNewEpisode: value })}
|
||||
label="Neue Episoden"
|
||||
hint="Wenn bei einer Serie in deiner Bibliothek eine Folge dazukommt."
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.notifyRelease}
|
||||
onChange={(value) => setState({ ...state, notifyRelease: value })}
|
||||
label="Neue Releases"
|
||||
hint="Kinostart oder Heimkinostart eines Films auf deiner Watchlist."
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.notifyAvailability}
|
||||
onChange={(value) => setState({ ...state, notifyAvailability: value })}
|
||||
label="Neu verfügbar"
|
||||
hint="Wenn ein Watchlist-Titel auf einem Dienst in deiner Region auftaucht."
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{publicProfilesEnabled && (
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||
Öffentliches Profil
|
||||
</legend>
|
||||
<Toggle
|
||||
checked={state.publicProfile}
|
||||
onChange={(value) => setState({ ...state, publicProfile: value })}
|
||||
label="Profil öffentlich zeigen"
|
||||
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||
/>
|
||||
{state.publicProfile && (
|
||||
<Field
|
||||
id="profileSlug"
|
||||
label="Adresse"
|
||||
value={state.profileSlug ?? ""}
|
||||
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
|
||||
placeholder="dein-name"
|
||||
hint="Erreichbar unter /u/<adresse>"
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
{pending ? "Einen Moment …" : "Speichern"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordForm() {
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(formData) =>
|
||||
startTransition(async () => {
|
||||
const result = await changePasswordAction({
|
||||
current: String(formData.get("current") ?? ""),
|
||||
next: String(formData.get("next") ?? ""),
|
||||
repeat: String(formData.get("repeat") ?? ""),
|
||||
});
|
||||
report(result);
|
||||
if (result.ok) window.location.href = "/anmelden";
|
||||
})
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field id="current" label="Bisheriges Passwort" type="password" autoComplete="current-password" required />
|
||||
<Field
|
||||
id="next"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
hint="Mindestens 10 Zeichen."
|
||||
/>
|
||||
<Field id="repeat" label="Neues Passwort wiederholen" type="password" autoComplete="new-password" required />
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Nach dem Wechsel wirst du auf allen Geräten abgemeldet.
|
||||
</p>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||
Passwort ändern
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API-Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ApiKeys({
|
||||
keys,
|
||||
}: {
|
||||
keys: { id: string; name: string; prefix: string; lastUsedAt: Date | null; createdAt: Date }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("keyName") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
const result = await createApiKeyAction(name);
|
||||
report(result);
|
||||
if (result.secret) setSecret(result.secret);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<div className="min-w-48 flex-1 space-y-1.5">
|
||||
<label htmlFor="keyName" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Neuer Schlüssel
|
||||
</label>
|
||||
<input id="keyName" name="keyName" placeholder="z. B. Handy-Skript" className="fl-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{secret && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3"
|
||||
style={{
|
||||
borderColor: "color-mix(in oklab, var(--fl-warning) 45%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--fl-warning) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--fl-warning)]">
|
||||
Einmalig sichtbar — jetzt kopieren:
|
||||
</p>
|
||||
<code className="mt-2 block break-all rounded-lg bg-[var(--fl-surface-2)] p-2 font-mono text-xs">
|
||||
{secret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(secret);
|
||||
report({ ok: true, message: "In die Zwischenablage kopiert" });
|
||||
}}
|
||||
className="fl-btn fl-btn-ghost mt-2 px-3 py-1.5 text-sm"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch kein Schlüssel angelegt.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{keys.map((key) => (
|
||||
<li key={key.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<p className="font-mono text-xs text-[var(--fl-faint)]">
|
||||
{key.prefix}… · angelegt {formatDate(key.createdAt)}
|
||||
{key.lastUsedAt ? ` · zuletzt benutzt ${formatDate(key.lastUsedAt)}` : " · nie benutzt"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await revokeApiKeyAction(key.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Zurückziehen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benachrichtigungskanäle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHANNEL_FIELDS: Record<string, { key: string; label: string; placeholder: string }[]> = {
|
||||
EMAIL: [{ key: "email", label: "Abweichende Adresse", placeholder: "leer = Konto-Adresse" }],
|
||||
NTFY: [
|
||||
{ key: "server", label: "Server", placeholder: "https://ntfy.sh" },
|
||||
{ key: "topic", label: "Topic", placeholder: "flimmer-abc123" },
|
||||
{ key: "token", label: "Zugriffstoken", placeholder: "optional" },
|
||||
],
|
||||
GOTIFY: [
|
||||
{ key: "url", label: "Server", placeholder: "https://gotify.example.com" },
|
||||
{ key: "token", label: "Anwendungs-Token", placeholder: "AxB..." },
|
||||
],
|
||||
WEBHOOK: [
|
||||
{ key: "webhookUrl", label: "Adresse", placeholder: "https://discord.com/api/webhooks/…" },
|
||||
],
|
||||
};
|
||||
|
||||
const CHANNEL_LABEL: Record<string, string> = {
|
||||
EMAIL: "E-Mail",
|
||||
NTFY: "ntfy",
|
||||
GOTIFY: "Gotify",
|
||||
WEBHOOK: "Webhook (Discord, Slack, …)",
|
||||
};
|
||||
|
||||
export function ChannelList({
|
||||
channels,
|
||||
smtpReady,
|
||||
}: {
|
||||
channels: { id: string; kind: string; name: string; config: Record<string, string>; enabled: boolean }[];
|
||||
smtpReady: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [kind, setKind] = useState<string>("NTFY");
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{channels.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{channel.name}
|
||||
<span className="ml-2 text-xs font-normal text-[var(--fl-faint)]">
|
||||
{CHANNEL_LABEL[channel.kind] ?? channel.kind}
|
||||
</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{Object.entries(channel.config)
|
||||
.filter(([key]) => key !== "token")
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(" · ") || "ohne weitere Angaben"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
checked={channel.enabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await toggleChannelAction(channel.id, value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await testChannelAction(channel.id));
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
Testen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await deleteChannelAction(channel.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("channelName") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
report(await saveChannelAction({ kind, name, config }));
|
||||
setConfig({});
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="space-y-4 border-t border-[var(--fl-border)] pt-5"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Kanal hinzufügen</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="kind" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id="kind"
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
setKind(event.target.value);
|
||||
setConfig({});
|
||||
}}
|
||||
className="fl-input"
|
||||
>
|
||||
{Object.entries(CHANNEL_LABEL).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{kind === "EMAIL" && !smtpReady && (
|
||||
<p className="text-xs text-[var(--fl-warning)]">
|
||||
Für diese Instanz ist kein SMTP-Zugang eingerichtet — E-Mails können nicht verschickt
|
||||
werden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field id="channelName" label="Name" placeholder="z. B. Handy" required />
|
||||
|
||||
{(CHANNEL_FIELDS[kind] ?? []).map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor={`cfg-${field.key}`}
|
||||
className="block text-sm font-medium text-[var(--fl-muted)]"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`cfg-${field.key}`}
|
||||
value={config[field.key] ?? ""}
|
||||
onChange={(event) => setConfig({ ...config, [field.key]: event.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
className="fl-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Kanal anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ImportForm() {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [format, setFormat] = useState<"csv" | "trakt">("csv");
|
||||
const [summary, setSummary] = useState<string[] | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{(["csv", "trakt"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setFormat(option)}
|
||||
aria-pressed={format === option}
|
||||
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={
|
||||
format === option
|
||||
? {
|
||||
backgroundImage: "var(--fl-gradient)",
|
||||
color: "var(--fl-on-accent)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||
}
|
||||
>
|
||||
{option === "csv" ? "CSV" : "Trakt.tv (JSON)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
{format === "csv"
|
||||
? "Die Kopfzeile braucht mindestens eine Spalte mit dem Titel. Erkannt werden außerdem Typ, Status, Bewertung, TMDB, Jahr und Notiz."
|
||||
: "Ein Export aus Trakt.tv — Watchlist, History, Ratings oder Collection. Flimmer sucht selbst die Einträge heraus."}
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept={format === "csv" ? ".csv,text/csv" : ".json,application/json"}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setSummary(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const content = await file.text();
|
||||
const result = await importDataAction(format, content);
|
||||
report({ ok: result.ok, message: result.message });
|
||||
|
||||
if (result.summary) {
|
||||
setSummary([
|
||||
`${result.summary.processed} Zeilen gelesen`,
|
||||
`${result.summary.added} neu aufgenommen`,
|
||||
`${result.summary.skipped} übersprungen (schon vorhanden oder unvollständig)`,
|
||||
...result.summary.errors.slice(0, 5),
|
||||
]);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={pending}
|
||||
className="fl-input file:mr-3 file:rounded-lg file:border-0 file:bg-[var(--fl-surface-2)] file:px-3 file:py-1.5 file:text-sm file:text-[var(--fl-text)]"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Der Import überschreibt nichts: Titel, die schon in deiner Bibliothek liegen, bleiben
|
||||
unangetastet.
|
||||
</p>
|
||||
|
||||
{summary && (
|
||||
<ul className="rounded-xl border border-[var(--fl-border)] p-3 text-sm text-[var(--fl-muted)]">
|
||||
{summary.map((line, index) => (
|
||||
<li key={index}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verwaltung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AdminSettings({
|
||||
registrationEnabled,
|
||||
publicProfilesEnabled,
|
||||
instanceName,
|
||||
}: {
|
||||
registrationEnabled: boolean;
|
||||
publicProfilesEnabled: boolean;
|
||||
instanceName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
action={(formData) =>
|
||||
startTransition(async () => {
|
||||
report(await setInstanceNameAction(String(formData.get("instanceName") ?? "")));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<div className="min-w-48 flex-1">
|
||||
<Field id="instanceName" label="Name der Instanz" defaultValue={instanceName} />
|
||||
</div>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Toggle
|
||||
checked={registrationEnabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await setRegistrationAction(value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label="Registrierung offen"
|
||||
hint="Solange es kein einziges Konto gibt, bleibt die Registrierung unabhängig davon offen."
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
checked={publicProfilesEnabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await setPublicProfilesAction(value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label="Öffentliche Profile erlauben"
|
||||
hint="Schaltet die Profilseiten instanzweit ab, unabhängig von der Einstellung der Nutzer."
|
||||
/>
|
||||
|
||||
<div className="border-t border-[var(--fl-border)] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await runSyncAction());
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-primary"
|
||||
>
|
||||
{pending ? "Läuft …" : "Abgleich jetzt starten"}
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Frischt laufende Serien bei TMDB auf, verschickt fällige Benachrichtigungen und räumt den
|
||||
Papierkorb. Kann eine Weile dauern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
}: {
|
||||
user: { id: string; name: string; email: string; role: string; isActive: boolean; entries: number };
|
||||
isSelf: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{user.name}
|
||||
{user.role === "ADMIN" && (
|
||||
<span
|
||||
className="ml-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
>
|
||||
Verwaltung
|
||||
</span>
|
||||
)}
|
||||
{!user.isActive && (
|
||||
<span className="ml-2 text-[0.65rem] font-semibold text-[var(--fl-danger)]">gesperrt</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{user.email} · {user.entries} {user.entries === 1 ? "Titel" : "Titel"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setUserRoleAction(user.id, user.role === "ADMIN" ? "USER" : "ADMIN"));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
{user.role === "ADMIN" ? "Rechte entziehen" : "Zur Verwaltung"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setUserActiveAction(user.id, !user.isActive));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||
>
|
||||
{user.isActive ? "Sperren" : "Freigeben"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Seitennavigation der Einstellungen. */
|
||||
const ITEMS = [
|
||||
{ href: "/einstellungen", label: "Profil" },
|
||||
{ href: "/einstellungen/benachrichtigungen", label: "Benachrichtigungen" },
|
||||
{ href: "/einstellungen/api", label: "API-Schlüssel" },
|
||||
{ href: "/einstellungen/daten", label: "Import und Export" },
|
||||
];
|
||||
|
||||
export function SettingsNav({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Einstellungen"
|
||||
className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"
|
||||
>
|
||||
{ITEMS.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
"shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-[var(--fl-surface-2)] text-[var(--fl-text)]"
|
||||
: "text-[var(--fl-muted)] hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="shrink-0 rounded-lg px-3 py-2 text-sm font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Verwaltung
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user