"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 & { id: string; label: string; hint?: string }) { return (
{hint &&

{hint}

}
); } function Toggle({ checked, onChange, label, hint, disabled, }: { checked: boolean; onChange: (value: boolean) => void; label: string; hint?: string; disabled?: boolean; }) { return ( ); } // --------------------------------------------------------------------------- // Profil // --------------------------------------------------------------------------- export function ProfileForm({ user, publicProfilesEnabled, }: { user: { name: string; email: string; region: string; publicProfile: boolean; showAchievements: 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 (
startTransition(async () => { report( await updateProfileAction({ name: state.name, region: state.region, publicProfile: state.publicProfile, showAchievements: state.showAchievements, profileSlug: state.profileSlug || null, notifyNewEpisode: state.notifyNewEpisode, notifyRelease: state.notifyRelease, notifyAvailability: state.notifyAvailability, }), ); router.refresh(); }) } className="space-y-5" > setState({ ...state, name: event.target.value })} required />

{user.email}

setState({ ...state, region: event.target.value.toUpperCase() })} hint="Zwei Buchstaben nach ISO 3166 — bestimmt, welche Streamingdienste angezeigt werden (DE, AT, CH …)." />
Wovon soll Flimmer dir berichten? setState({ ...state, notifyNewEpisode: value })} label="Neue Episoden" hint="Wenn bei einer Serie in deiner Bibliothek eine Folge dazukommt." /> setState({ ...state, notifyRelease: value })} label="Neue Releases" hint="Kinostart oder Heimkinostart eines Films auf deiner Watchlist." /> setState({ ...state, notifyAvailability: value })} label="Neu verfügbar" hint="Wenn ein Watchlist-Titel auf einem Dienst in deiner Region auftaucht." />
{publicProfilesEnabled && (
Öffentliches Profil setState({ ...state, publicProfile: value })} label="Profil öffentlich zeigen" hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen." /> {state.publicProfile && ( <> setState({ ...state, profileSlug: event.target.value })} placeholder="dein-name" hint="Erreichbar unter /u/" /> setState({ ...state, showAchievements: value })} label="Errungenschaften zeigen" hint="Die erreichten Abzeichen erscheinen auf der Profilseite." /> )}
)} ); } export function PasswordForm() { const { report } = useToasts(); const [pending, startTransition] = useTransition(); return (
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" >

Nach dem Wechsel wirst du auf allen Geräten abgemeldet.

); } // --------------------------------------------------------------------------- // 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(null); return (
{ 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" >
{secret && (

Einmalig sichtbar — jetzt kopieren:

{secret}
)} {keys.length === 0 ? (

Noch kein Schlüssel angelegt.

) : (
    {keys.map((key) => (
  • {key.name}

    {key.prefix}… · angelegt {formatDate(key.createdAt)} {key.lastUsedAt ? ` · zuletzt benutzt ${formatDate(key.lastUsedAt)}` : " · nie benutzt"}

  • ))}
)}
); } // --------------------------------------------------------------------------- // Benachrichtigungskanäle // --------------------------------------------------------------------------- const CHANNEL_FIELDS: Record = { 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 = { 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; enabled: boolean }[]; smtpReady: boolean; }) { const router = useRouter(); const { report } = useToasts(); const [pending, startTransition] = useTransition(); const [kind, setKind] = useState("NTFY"); const [config, setConfig] = useState>({}); return (
{channels.length > 0 && (
    {channels.map((channel) => (
  • {channel.name} {CHANNEL_LABEL[channel.kind] ?? channel.kind}

    {Object.entries(channel.config) .filter(([key]) => key !== "token") .map(([key, value]) => `${key}: ${value}`) .join(" · ") || "ohne weitere Angaben"}

    startTransition(async () => { report(await toggleChannelAction(channel.id, value)); router.refresh(); }) } label="" />
  • ))}
)}
{ 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" >

Kanal hinzufügen

{kind === "EMAIL" && !smtpReady && (

Für diese Instanz ist kein SMTP-Zugang eingerichtet — E-Mails können nicht verschickt werden.

)}
{(CHANNEL_FIELDS[kind] ?? []).map((field) => (
setConfig({ ...config, [field.key]: event.target.value })} placeholder={field.placeholder} className="fl-input" />
))}
); } // --------------------------------------------------------------------------- // 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(null); return (
{(["csv", "trakt"] as const).map((option) => ( ))}

{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."}

{ 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)]" />

Der Import überschreibt nichts: Titel, die schon in deiner Bibliothek liegen, bleiben unangetastet.

{summary && (
    {summary.map((line, index) => (
  • {line}
  • ))}
)}
); } // --------------------------------------------------------------------------- // Verwaltung // --------------------------------------------------------------------------- export function AdminSettings({ registrationEnabled, publicProfilesEnabled, instanceName, }: { registrationEnabled: boolean; publicProfilesEnabled: boolean; instanceName: string; }) { const router = useRouter(); const { report } = useToasts(); const [pending, startTransition] = useTransition(); return (
startTransition(async () => { report(await setInstanceNameAction(String(formData.get("instanceName") ?? ""))); router.refresh(); }) } className="flex flex-wrap items-end gap-2" >
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." /> 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." />

Frischt laufende Serien bei TMDB auf, verschickt fällige Benachrichtigungen und räumt den Papierkorb. Kann eine Weile dauern.

); } 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 (
  • {user.name} {user.role === "ADMIN" && ( Verwaltung )} {!user.isActive && ( gesperrt )}

    {user.email} · {user.entries} {user.entries === 1 ? "Titel" : "Titel"}

    {!isSelf && ( <> )}
  • ); }