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,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<SettingsResult> {
|
||||||
|
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<ImportResult> {
|
||||||
|
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]}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <dein-schlüssel>" \\',
|
||||||
|
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<ApiKeys keys={keys} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
|
||||||
|
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
|
||||||
|
{example}
|
||||||
|
</pre>
|
||||||
|
<p className="mt-3 text-sm text-[var(--fl-muted)]">
|
||||||
|
Die vollständige Beschreibung steht in{" "}
|
||||||
|
<a
|
||||||
|
href="/api/v1/openapi.json"
|
||||||
|
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
openapi.json
|
||||||
|
</a>{" "}
|
||||||
|
und in <code>docs/API.md</code> im Repository.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Kanäle</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
|
||||||
|
</p>
|
||||||
|
<ChannelList
|
||||||
|
channels={channels.map((channel) => ({
|
||||||
|
id: channel.id,
|
||||||
|
kind: channel.kind,
|
||||||
|
name: channel.name,
|
||||||
|
config: (channel.config ?? {}) as Record<string, string>,
|
||||||
|
enabled: channel.enabled,
|
||||||
|
}))}
|
||||||
|
smtpReady={env.smtp.isConfigured}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)] text-sm">
|
||||||
|
{recent.map((entry) => (
|
||||||
|
<li key={entry.id} className="py-2.5">
|
||||||
|
<p className="font-medium">{entry.subject}</p>
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
{entry.sentAt.toLocaleString("de-DE")}
|
||||||
|
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Import</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Bringt Titel aus einem anderen Werkzeug mit.
|
||||||
|
</p>
|
||||||
|
<ImportForm />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Export</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
|
||||||
|
Als JSON
|
||||||
|
</a>
|
||||||
|
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
|
||||||
|
Als CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
||||||
|
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
|
||||||
|
für Tabellenkalkulationen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AppShell user={user} instanceName={settings.instanceName}>
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||||
|
<SettingsNav isAdmin={user.role === "ADMIN"} />
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
|
||||||
|
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{passwordHash && (
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
|
||||||
|
<PasswordForm />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Metadata> {
|
||||||
|
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 (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<Link href="/" aria-label="Flimmer">
|
||||||
|
<FlimmerLogo />
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||||
|
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
|
||||||
|
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||||
|
Öffentliches Profil
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
|
||||||
|
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||||
|
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||||
|
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||||
|
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||||
|
{recent.map((watch) => (
|
||||||
|
<li key={watch.id}>
|
||||||
|
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
|
||||||
|
{watch.title.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
{formatRelative(watch.watchedAt)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
|
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
|
||||||
|
<TitleList heading="Bestbewertet" items={topRated} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totals.minutes === 0 && (
|
||||||
|
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||||
|
Hier ist noch nichts zu sehen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
|
||||||
|
Erstellt mit Flimmer · Metadaten von TMDB
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||||
|
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={item.titleId} className="flex items-center gap-3">
|
||||||
|
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="w-10 shrink-0">
|
||||||
|
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||||
|
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||||
|
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AppShell user={admin} instanceName={settings.instanceName}>
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Einstellungen der Instanz, Konten und Hintergrundlauf.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
|
||||||
|
<AdminSettings
|
||||||
|
registrationEnabled={settings.registrationEnabled}
|
||||||
|
publicProfilesEnabled={settings.publicProfilesEnabled}
|
||||||
|
instanceName={settings.instanceName}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<Fact label="Konten" value={String(users.length)} />
|
||||||
|
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
|
||||||
|
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
|
||||||
|
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
|
||||||
|
<Fact
|
||||||
|
label="TMDB"
|
||||||
|
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
|
||||||
|
/>
|
||||||
|
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||||
|
<Fact
|
||||||
|
label="Zeitplan"
|
||||||
|
value={
|
||||||
|
env.scheduler.enabled
|
||||||
|
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
|
||||||
|
: "abgeschaltet"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
|
||||||
|
{lastRun ? (
|
||||||
|
<p className="text-sm text-[var(--fl-muted)]">
|
||||||
|
{formatRelative(lastRun.startedAt)} ·{" "}
|
||||||
|
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
|
||||||
|
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
|
||||||
|
</span>
|
||||||
|
{lastRun.message ? ` · ${lastRun.message}` : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="fl-card mt-6 p-5">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)]">
|
||||||
|
{users.map((user) => (
|
||||||
|
<UserRow
|
||||||
|
key={user.id}
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
entries: user._count.entries,
|
||||||
|
}}
|
||||||
|
isSelf={user.id === admin.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||||
|
<dd className="mt-0.5 font-medium">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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