From a9314acbb40199ec30893669d56da81e045bf04d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 20:25:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Einstellungen,=20Verwaltung=20und=20?= =?UTF-8?q?=C3=B6ffentliches=20Profil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/actions/settings.ts | 306 +++++++ src/app/einstellungen/api/page.tsx | 52 ++ .../einstellungen/benachrichtigungen/page.tsx | 63 ++ src/app/einstellungen/daten/page.tsx | 46 ++ src/app/einstellungen/layout.tsx | 23 + src/app/einstellungen/page.tsx | 50 ++ src/app/u/[slug]/page.tsx | 171 ++++ src/app/verwaltung/page.tsx | 129 +++ src/components/settings/Forms.tsx | 757 ++++++++++++++++++ src/components/settings/SettingsNav.tsx | 52 ++ 10 files changed, 1649 insertions(+) create mode 100644 src/app/actions/settings.ts create mode 100644 src/app/einstellungen/api/page.tsx create mode 100644 src/app/einstellungen/benachrichtigungen/page.tsx create mode 100644 src/app/einstellungen/daten/page.tsx create mode 100644 src/app/einstellungen/layout.tsx create mode 100644 src/app/einstellungen/page.tsx create mode 100644 src/app/u/[slug]/page.tsx create mode 100644 src/app/verwaltung/page.tsx create mode 100644 src/components/settings/Forms.tsx create mode 100644 src/components/settings/SettingsNav.tsx diff --git a/src/app/actions/settings.ts b/src/app/actions/settings.ts new file mode 100644 index 0000000..ef01ece --- /dev/null +++ b/src/app/actions/settings.ts @@ -0,0 +1,306 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + checkPasswordStrength, + createApiKey, + destroyAllSessions, + getSessionUser, + hashPassword, + verifyPassword, +} from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { setSetting } from "@/lib/settings"; +import { sendTestNotification } from "@/lib/notify"; +import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer"; +import { runSync } from "@/lib/sync"; + +/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */ + +export type SettingsResult = { ok: boolean; message?: string; secret?: string }; + +async function run( + fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>, +): Promise { + const user = await getSessionUser(); + if (!user) return { ok: false, message: "Bitte melde dich an." }; + + try { + const result = (await fn(user.id)) ?? {}; + revalidatePath("/einstellungen", "layout"); + revalidatePath("/", "layout"); + return { ok: true, ...result }; + } catch (error) { + console.error("Einstellungs-Aktion fehlgeschlagen:", error); + return { + ok: false, + message: error instanceof Error ? error.message : "Das hat nicht geklappt.", + }; + } +} + +async function requireAdminUser() { + const user = await getSessionUser(); + if (!user || user.role !== "ADMIN") { + throw new Error("Dafür fehlen dir die Rechte."); + } + return user; +} + +// --------------------------------------------------------------------------- +// Profil +// --------------------------------------------------------------------------- + +const profileSchema = z.object({ + name: z.string().trim().min(1).max(80), + region: z.string().trim().length(2).toUpperCase(), + publicProfile: z.boolean(), + profileSlug: z + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.") + .nullable(), + notifyNewEpisode: z.boolean(), + notifyRelease: z.boolean(), + notifyAvailability: z.boolean(), +}); + +export async function updateProfileAction(data: unknown) { + return run(async (userId) => { + const parsed = profileSchema.parse(data); + + if (parsed.publicProfile && !parsed.profileSlug) { + throw new Error("Für ein öffentliches Profil braucht es eine Adresse."); + } + + if (parsed.profileSlug) { + const taken = await prisma.user.findFirst({ + where: { profileSlug: parsed.profileSlug, id: { not: userId } }, + select: { id: true }, + }); + if (taken) throw new Error("Diese Profiladresse ist schon vergeben."); + } + + await prisma.user.update({ where: { id: userId }, data: parsed }); + return { message: "Gespeichert" }; + }); +} + +const passwordSchema = z.object({ + current: z.string().min(1), + next: z.string().min(1), + repeat: z.string().min(1), +}); + +export async function changePasswordAction(data: unknown) { + return run(async (userId) => { + const parsed = passwordSchema.parse(data); + + if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein."); + const weak = checkPasswordStrength(parsed.next); + if (weak) throw new Error(weak); + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { passwordHash: true }, + }); + if (!user.passwordHash) { + throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort."); + } + if (!(await verifyPassword(parsed.current, user.passwordHash))) { + throw new Error("Das bisherige Passwort stimmt nicht."); + } + + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash: await hashPassword(parsed.next) }, + }); + + // Nach einem Passwortwechsel fliegen alle anderen Geräte raus. + await destroyAllSessions(userId); + return { message: "Passwort geändert — bitte neu anmelden." }; + }); +} + +// --------------------------------------------------------------------------- +// API-Schlüssel +// --------------------------------------------------------------------------- + +export async function createApiKeyAction(name: string) { + return run(async (userId) => { + const clean = z.string().trim().min(1).max(60).parse(name); + const { plain } = await createApiKey(userId, clean); + // Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash. + return { message: "Schlüssel angelegt", secret: plain }; + }); +} + +export async function revokeApiKeyAction(keyId: string) { + return run(async (userId) => { + await prisma.apiKey.updateMany({ + where: { id: keyId, userId }, + data: { revokedAt: new Date() }, + }); + return { message: "Schlüssel zurückgezogen" }; + }); +} + +// --------------------------------------------------------------------------- +// Benachrichtigungskanäle +// --------------------------------------------------------------------------- + +const channelSchema = z.object({ + kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]), + name: z.string().trim().min(1).max(60), + config: z.record(z.string(), z.string().max(500)).default({}), +}); + +export async function saveChannelAction(data: unknown, channelId?: string) { + return run(async (userId) => { + const parsed = channelSchema.parse(data); + + if (channelId) { + await prisma.notificationChannel.updateMany({ + where: { id: channelId, userId }, + data: { kind: parsed.kind, name: parsed.name, config: parsed.config }, + }); + return { message: "Kanal gespeichert" }; + } + + await prisma.notificationChannel.create({ + data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config }, + }); + return { message: "Kanal angelegt" }; + }); +} + +export async function toggleChannelAction(channelId: string, enabled: boolean) { + return run(async (userId) => { + await prisma.notificationChannel.updateMany({ + where: { id: channelId, userId }, + data: { enabled }, + }); + return { message: enabled ? "Kanal aktiv" : "Kanal ruht" }; + }); +} + +export async function deleteChannelAction(channelId: string) { + return run(async (userId) => { + await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } }); + return { message: "Kanal entfernt" }; + }); +} + +export async function testChannelAction(channelId: string) { + return run(async (userId) => { + await sendTestNotification(userId, channelId); + return { message: "Testnachricht verschickt" }; + }); +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary }; + +export async function importDataAction( + format: "csv" | "trakt", + content: string, +): Promise { + const user = await getSessionUser(); + if (!user) return { ok: false, message: "Bitte melde dich an." }; + + if (content.length > 8_000_000) { + return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." }; + } + + try { + const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content); + revalidatePath("/bibliothek", "layout"); + revalidatePath("/", "layout"); + + return { + ok: true, + message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`, + summary, + }; + } catch (error) { + console.error("Import fehlgeschlagen:", error); + return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." }; + } +} + +// --------------------------------------------------------------------------- +// Verwaltung +// --------------------------------------------------------------------------- + +export async function setRegistrationAction(enabled: boolean) { + return run(async () => { + await requireAdminUser(); + await setSetting("registrationEnabled", enabled); + return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" }; + }); +} + +export async function setInstanceNameAction(name: string) { + return run(async () => { + await requireAdminUser(); + await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name)); + return { message: "Name gespeichert" }; + }); +} + +export async function setPublicProfilesAction(enabled: boolean) { + return run(async () => { + await requireAdminUser(); + await setSetting("publicProfilesEnabled", enabled); + return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" }; + }); +} + +export async function setUserActiveAction(targetId: string, active: boolean) { + return run(async (userId) => { + await requireAdminUser(); + if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren."); + + await prisma.user.update({ where: { id: targetId }, data: { isActive: active } }); + if (!active) await destroyAllSessions(targetId); + + revalidatePath("/verwaltung", "layout"); + return { message: active ? "Konto freigegeben" : "Konto gesperrt" }; + }); +} + +export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") { + return run(async (userId) => { + await requireAdminUser(); + if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern."); + + if (role === "USER") { + const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } }); + if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben."); + } + + await prisma.user.update({ where: { id: targetId }, data: { role } }); + revalidatePath("/verwaltung", "layout"); + return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" }; + }); +} + +export async function runSyncAction() { + return run(async () => { + await requireAdminUser(); + const stats = await runSync({ limit: 40 }); + revalidatePath("/verwaltung", "layout"); + + return { + message: + stats.errors.length === 0 + ? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen` + : `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`, + }; + }); +} diff --git a/src/app/einstellungen/api/page.tsx b/src/app/einstellungen/api/page.tsx new file mode 100644 index 0000000..5f52059 --- /dev/null +++ b/src/app/einstellungen/api/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { env } from "@/lib/env"; +import { ApiKeys } from "@/components/settings/Forms"; + +export const metadata: Metadata = { title: "API-Schlüssel" }; + +export default async function ApiPage() { + const user = await requireUser("/einstellungen/api"); + + const keys = await prisma.apiKey.findMany({ + where: { userId: user.id, revokedAt: null }, + orderBy: { createdAt: "desc" }, + select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true }, + }); + + const example = [ + 'curl -H "Authorization: Bearer " \\', + ` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`, + ].join("\n"); + + return ( +
+
+

API-Schlüssel

+

+ Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto — + gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst. +

+ +
+ +
+

So wird er benutzt

+
+          {example}
+        
+

+ Die vollständige Beschreibung steht in{" "} + + openapi.json + {" "} + und in docs/API.md im Repository. +

+
+
+ ); +} diff --git a/src/app/einstellungen/benachrichtigungen/page.tsx b/src/app/einstellungen/benachrichtigungen/page.tsx new file mode 100644 index 0000000..715aeac --- /dev/null +++ b/src/app/einstellungen/benachrichtigungen/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { env } from "@/lib/env"; +import { ChannelList } from "@/components/settings/Forms"; + +export const metadata: Metadata = { title: "Benachrichtigungen" }; + +export default async function NotificationsPage() { + const user = await requireUser("/einstellungen/benachrichtigungen"); + + const [channels, recent] = await Promise.all([ + prisma.notificationChannel.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "asc" }, + }), + prisma.notificationLog.findMany({ + where: { userId: user.id }, + orderBy: { sentAt: "desc" }, + take: 10, + }), + ]); + + return ( +
+
+

Kanäle

+

+ Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein. +

+ ({ + id: channel.id, + kind: channel.kind, + name: channel.name, + config: (channel.config ?? {}) as Record, + enabled: channel.enabled, + }))} + smtpReady={env.smtp.isConfigured} + /> +
+ +
+

Zuletzt verschickt

+ {recent.length === 0 ? ( +

Noch nichts verschickt.

+ ) : ( +
    + {recent.map((entry) => ( +
  • +

    {entry.subject}

    +

    + {entry.sentAt.toLocaleString("de-DE")} + {entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`} +

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/einstellungen/daten/page.tsx b/src/app/einstellungen/daten/page.tsx new file mode 100644 index 0000000..5af1276 --- /dev/null +++ b/src/app/einstellungen/daten/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { ImportForm } from "@/components/settings/Forms"; + +export const metadata: Metadata = { title: "Import und Export" }; + +export default async function DataPage() { + const user = await requireUser("/einstellungen/daten"); + + const [entries, watches] = await Promise.all([ + prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }), + prisma.watch.count({ where: { userId: user.id } }), + ]); + + return ( +
+
+

Import

+

+ Bringt Titel aus einem anderen Werkzeug mit. +

+ +
+ +
+

Export

+

+ Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen. +

+ +

+ JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform + für Tabellenkalkulationen. +

+
+
+ ); +} diff --git a/src/app/einstellungen/layout.tsx b/src/app/einstellungen/layout.tsx new file mode 100644 index 0000000..8e07d5b --- /dev/null +++ b/src/app/einstellungen/layout.tsx @@ -0,0 +1,23 @@ +import { requireUser } from "@/lib/auth"; +import { getSettings } from "@/lib/settings"; +import { AppShell } from "@/components/AppShell"; +import { SettingsNav } from "@/components/settings/SettingsNav"; + +/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */ +export default async function SettingsLayout({ children }: { children: React.ReactNode }) { + const user = await requireUser("/einstellungen"); + const settings = await getSettings(); + + return ( + +
+

Einstellungen

+
+ +
+ +
{children}
+
+
+ ); +} diff --git a/src/app/einstellungen/page.tsx b/src/app/einstellungen/page.tsx new file mode 100644 index 0000000..33d40e9 --- /dev/null +++ b/src/app/einstellungen/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next"; +import { requireUser } from "@/lib/auth"; +import { getSettings } from "@/lib/settings"; +import { prisma } from "@/lib/db"; +import { PasswordForm, ProfileForm } from "@/components/settings/Forms"; +import pkg from "../../../package.json"; + +export const metadata: Metadata = { title: "Einstellungen" }; + +export default async function SettingsPage() { + const session = await requireUser("/einstellungen"); + const settings = await getSettings(); + + const user = await prisma.user.findUniqueOrThrow({ + where: { id: session.id }, + select: { + name: true, + email: true, + region: true, + publicProfile: true, + profileSlug: true, + notifyNewEpisode: true, + notifyRelease: true, + notifyAvailability: true, + passwordHash: true, + }, + }); + + const { passwordHash, ...profile } = user; + + return ( +
+
+

Profil

+ +
+ + {passwordHash && ( +
+

Passwort

+ +
+ )} + +

+ Flimmer {pkg.version} · Instanz {settings.instanceName} +

+
+ ); +} diff --git a/src/app/u/[slug]/page.tsx b/src/app/u/[slug]/page.tsx new file mode 100644 index 0000000..ec2ee94 --- /dev/null +++ b/src/app/u/[slug]/page.tsx @@ -0,0 +1,171 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { prisma } from "@/lib/db"; +import { getSettings } from "@/lib/settings"; +import { getMostWatched, getTopRated, getTotals } from "@/lib/stats"; +import { formatMinutes, formatRelative } from "@/lib/labels"; +import { FlimmerLogo } from "@/components/brand/FlimmerLogo"; +import { Poster } from "@/components/Poster"; +import { ThemeToggle } from "@/components/ThemeToggle"; + +type Params = Promise<{ slug: string }>; + +/** + * Öffentliche Profilseite. + * + * Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist, + * keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist + * nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber + * öffentliche Profile instanzweit erlaubt. + */ +export async function generateMetadata({ params }: { params: Params }): Promise { + const { slug } = await params; + const user = await prisma.user.findFirst({ + where: { profileSlug: slug, publicProfile: true, isActive: true }, + select: { name: true }, + }); + return user ? { title: `${user.name} · Profil` } : { title: "Profil" }; +} + +export default async function PublicProfilePage({ params }: { params: Params }) { + const { slug } = await params; + const settings = await getSettings(); + if (!settings.publicProfilesEnabled) notFound(); + + const user = await prisma.user.findFirst({ + where: { profileSlug: slug, publicProfile: true, isActive: true }, + select: { id: true, name: true, createdAt: true }, + }); + if (!user) notFound(); + + const [totals, mostWatched, topRated, recent] = await Promise.all([ + getTotals(user.id), + getMostWatched(user.id, 6), + getTopRated(user.id, 6), + prisma.watch.findMany({ + where: { userId: user.id }, + orderBy: { watchedAt: "desc" }, + take: 8, + distinct: ["titleId"], + include: { + title: { select: { id: true, title: true, posterPath: true, type: true } }, + episode: { select: { seasonNumber: true, episodeNumber: true } }, + }, + }), + ]); + + return ( +
+
+ + + + +
+ +
+
+
+
+

+ Öffentliches Profil +

+

{user.name}

+

+ Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })} +

+ +
+ + + + +
+
+
+ + {recent.length > 0 && ( +
+

Zuletzt gesehen

+
    + {recent.map((watch) => ( +
  • + +

    + {watch.title.title} +

    +

    + {formatRelative(watch.watchedAt)} +

    +
  • + ))} +
+
+ )} + +
+ + +
+ + {totals.minutes === 0 && ( +

+ Hier ist noch nichts zu sehen. +

+ )} +
+ +
+ Erstellt mit Flimmer · Metadaten von TMDB +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function TitleList({ + heading, + items, + showMinutes = false, +}: { + heading: string; + items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[]; + showMinutes?: boolean; +}) { + if (items.length === 0) return null; + + return ( +
+

{heading}

+
    + {items.map((item, index) => ( +
  1. + + {index + 1} + + + + + {item.title} + + {showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"} + +
  2. + ))} +
+
+ ); +} diff --git a/src/app/verwaltung/page.tsx b/src/app/verwaltung/page.tsx new file mode 100644 index 0000000..b14eeeb --- /dev/null +++ b/src/app/verwaltung/page.tsx @@ -0,0 +1,129 @@ +import type { Metadata } from "next"; +import { requireAdmin } from "@/lib/auth"; +import { getSettings } from "@/lib/settings"; +import { prisma } from "@/lib/db"; +import { env } from "@/lib/env"; +import { AppShell } from "@/components/AppShell"; +import { AdminSettings, UserRow } from "@/components/settings/Forms"; +import { formatRelative } from "@/lib/labels"; + +export const metadata: Metadata = { title: "Verwaltung" }; + +export default async function AdminPage() { + const admin = await requireAdmin(); + const settings = await getSettings(); + + const [users, lastRun, catalog] = await Promise.all([ + prisma.user.findMany({ + orderBy: { createdAt: "asc" }, + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + _count: { select: { entries: true } }, + }, + }), + prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }), + Promise.all([ + prisma.title.count(), + prisma.episode.count(), + prisma.watch.count(), + ]), + ]); + + const [titleCount, episodeCount, watchCount] = catalog; + + return ( + +
+

Verwaltung

+

+ Einstellungen der Instanz, Konten und Hintergrundlauf. +

+
+ +
+
+

Instanz

+ +
+ +
+
+

Zustand

+
+ + + + + + + + +
+
+ +
+

Letzter Abgleich

+ {lastRun ? ( +

+ {formatRelative(lastRun.startedAt)} ·{" "} + + {lastRun.ok ? "erfolgreich" : "mit Fehlern"} + + {lastRun.message ? ` · ${lastRun.message}` : ""} +

+ ) : ( +

Noch kein Abgleich gelaufen.

+ )} +
+
+
+ +
+

Konten

+
    + {users.map((user) => ( + + ))} +
+
+
+ ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/components/settings/Forms.tsx b/src/components/settings/Forms.tsx new file mode 100644 index 0000000..83d0e74 --- /dev/null +++ b/src/components/settings/Forms.tsx @@ -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 & { 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; + 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, + 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/" + /> + )} +
+ )} + + + + ); +} + +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 && ( + <> + + + + )} +
  • + ); +} diff --git a/src/components/settings/SettingsNav.tsx b/src/components/settings/SettingsNav.tsx new file mode 100644 index 0000000..ef74847 --- /dev/null +++ b/src/components/settings/SettingsNav.tsx @@ -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 ( + + ); +}