3053 lines
109 KiB
TypeScript
3053 lines
109 KiB
TypeScript
// frontend\src\components\ui\RecorderSettings.tsx
|
||
'use client'
|
||
|
||
import { useEffect, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||
import Button from './Button'
|
||
import Card from './Card'
|
||
import LabeledSwitch from './LabeledSwitch'
|
||
import Task from './Task'
|
||
import TaskList from './TaskList'
|
||
import type { TaskItem } from './TaskList'
|
||
import PostgresUrlModal from './PostgresUrlModal'
|
||
import { CheckIcon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/solid'
|
||
import { startRegistration } from '@simplewebauthn/browser'
|
||
|
||
type RecorderSettings = {
|
||
databaseUrl?: string
|
||
hasDbPassword?: boolean
|
||
recordDir: string
|
||
doneDir: string
|
||
ffmpegPath?: string
|
||
autoAddToDownloadList?: boolean
|
||
autoStartAddedDownloads?: boolean
|
||
enableConcurrentDownloadsLimit?: boolean
|
||
maxConcurrentDownloads?: number
|
||
useProviderApis?: boolean
|
||
useChaturbateApi?: boolean
|
||
useMyFreeCamsApi?: boolean
|
||
autoDeleteSmallDownloads?: boolean
|
||
autoDeleteSmallDownloadsBelowMB?: number
|
||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||
autoDeleteLowRatedDownloads?: boolean
|
||
autoDeleteLowRatedDownloadsMaxStars?: number
|
||
blurPreviews?: boolean
|
||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||
teaserAudio?: boolean
|
||
lowDiskPauseBelowGB?: number
|
||
enableNotifications?: boolean
|
||
|
||
generateAssetsMeta?: boolean
|
||
generateAssetsThumb?: boolean
|
||
generateAssetsTeaser?: boolean
|
||
generateAssetsSprites?: boolean
|
||
generateAssetsAnalyze?: boolean
|
||
enrichPostworkEnabled?: boolean
|
||
|
||
trainingRecognitionEnabled?: boolean
|
||
trainingDetectorEpochs?: number
|
||
}
|
||
|
||
type AuthDevice = {
|
||
id: string
|
||
label: string
|
||
type?: string
|
||
createdAt?: string
|
||
configuredAt?: string
|
||
lastUsedAt?: string
|
||
active?: boolean
|
||
signCount?: number
|
||
backupState?: boolean
|
||
backupEligible?: boolean
|
||
}
|
||
|
||
type AuthSecurityStatus = {
|
||
authenticated?: boolean
|
||
totpEnabled?: boolean
|
||
totpConfigured?: boolean
|
||
passkeyConfigured?: boolean
|
||
passkeyCount?: number
|
||
totpDevice?: AuthDevice | null
|
||
passkeys?: AuthDevice[]
|
||
}
|
||
|
||
type AIServerStatus = {
|
||
ok: boolean
|
||
running: boolean
|
||
url?: string
|
||
statusCode?: number
|
||
error?: string
|
||
}
|
||
|
||
type AppLog = {
|
||
ok: boolean
|
||
exists?: boolean
|
||
path?: string
|
||
sizeBytes?: number
|
||
log?: string
|
||
error?: string
|
||
}
|
||
|
||
type DiskStatus = {
|
||
emergency: boolean
|
||
pauseGB: number
|
||
resumeGB: number
|
||
freeBytes: number
|
||
freeBytesHuman: string
|
||
recordPath?: string
|
||
}
|
||
|
||
function unwrapWebAuthnOptions<T = any>(options: any): T {
|
||
const unwrapped = options?.publicKey ?? options
|
||
|
||
if (!unwrapped?.challenge) {
|
||
throw new Error('Passkey Setup fehlgeschlagen: challenge fehlt in den WebAuthn-Options.')
|
||
}
|
||
|
||
return unwrapped as T
|
||
}
|
||
|
||
function formatSecurityDate(value?: string) {
|
||
const raw = String(value ?? '').trim()
|
||
if (!raw) return 'Unbekannt'
|
||
|
||
const d = new Date(raw)
|
||
if (!Number.isFinite(d.getTime())) return 'Unbekannt'
|
||
|
||
return d.toLocaleString('de-DE', {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
})
|
||
}
|
||
|
||
function statusPill(active: boolean, activeText = 'Aktiv', inactiveText = 'Aus') {
|
||
return (
|
||
<span
|
||
className={[
|
||
'shrink-0 rounded-full px-2 py-1 text-[11px] font-bold ring-1',
|
||
active
|
||
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
|
||
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
|
||
].join(' ')}
|
||
>
|
||
{active ? activeText : inactiveText}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function SecurityDeviceRow(props: {
|
||
icon: string
|
||
title: string
|
||
subtitle: string
|
||
meta?: string
|
||
badge?: string
|
||
actions?: ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg shadow-sm ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
|
||
{props.icon}
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||
{props.title}
|
||
</div>
|
||
|
||
{props.badge ? (
|
||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
|
||
{props.badge}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
||
{props.subtitle}
|
||
</div>
|
||
|
||
{props.meta ? (
|
||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||
{props.meta}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{props.actions ? (
|
||
<div className="shrink-0">
|
||
{props.actions}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SettingsSection(props: {
|
||
id: string
|
||
title: string
|
||
description?: string
|
||
icon?: ReactNode
|
||
actions?: ReactNode
|
||
defaultOpen?: boolean
|
||
children: ReactNode
|
||
}) {
|
||
const storageKey = `recorder-settings:section:${props.id}:open`
|
||
|
||
const [open, setOpen] = useState(() => {
|
||
if (typeof window === 'undefined') return props.defaultOpen ?? true
|
||
|
||
try {
|
||
const raw = window.localStorage.getItem(storageKey)
|
||
if (raw === '1') return true
|
||
if (raw === '0') return false
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
return props.defaultOpen ?? true
|
||
})
|
||
|
||
function toggleOpen() {
|
||
setOpen((cur) => {
|
||
const next = !cur
|
||
|
||
try {
|
||
window.localStorage.setItem(storageKey, next ? '1' : '0')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
return next
|
||
})
|
||
}
|
||
|
||
return (
|
||
<section className="overflow-hidden rounded-2xl bg-white shadow-md shadow-gray-200/70 ring-1 ring-gray-300/80 transition-shadow hover:shadow-lg dark:bg-gray-950/70 dark:shadow-black/30 dark:ring-white/15">
|
||
<button
|
||
type="button"
|
||
onClick={toggleOpen}
|
||
className={[
|
||
'flex w-full items-start justify-between gap-4 p-4 text-left transition',
|
||
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500',
|
||
'dark:focus-visible:ring-indigo-400',
|
||
open
|
||
? 'rounded-t-2xl rounded-b-none bg-gray-50 hover:bg-gray-100/80 dark:bg-white/5 dark:hover:bg-white/10'
|
||
: 'rounded-2xl bg-white hover:bg-gray-50 dark:bg-gray-950/70 dark:hover:bg-white/5',
|
||
].join(' ')}
|
||
aria-expanded={open}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex items-start gap-3">
|
||
{props.icon ? (
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10">
|
||
{props.icon}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{props.title}
|
||
</div>
|
||
|
||
{props.description ? (
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
{props.description}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{props.actions}
|
||
|
||
<ChevronDownIcon
|
||
className={[
|
||
'h-5 w-5 text-gray-400 transition-transform duration-200 dark:text-gray-500',
|
||
open ? 'rotate-180' : '',
|
||
].join(' ')}
|
||
/>
|
||
</div>
|
||
</button>
|
||
|
||
{open ? (
|
||
<div className="border-t border-gray-200 p-4 pt-3 dark:border-white/10">
|
||
{props.children}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
const DEFAULTS: RecorderSettings = {
|
||
databaseUrl: '',
|
||
hasDbPassword: false,
|
||
recordDir: 'record',
|
||
doneDir: 'record/done',
|
||
ffmpegPath: '',
|
||
autoAddToDownloadList: true,
|
||
autoStartAddedDownloads: true,
|
||
enableConcurrentDownloadsLimit: false,
|
||
maxConcurrentDownloads: 20,
|
||
useProviderApis: false,
|
||
useChaturbateApi: false,
|
||
useMyFreeCamsApi: false,
|
||
autoDeleteSmallDownloads: true,
|
||
autoDeleteSmallDownloadsBelowMB: 200,
|
||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||
autoDeleteLowRatedDownloads: false,
|
||
autoDeleteLowRatedDownloadsMaxStars: 3,
|
||
blurPreviews: false,
|
||
teaserPlayback: 'hover',
|
||
teaserAudio: false,
|
||
lowDiskPauseBelowGB: 5,
|
||
enableNotifications: true,
|
||
|
||
generateAssetsMeta: true,
|
||
generateAssetsThumb: true,
|
||
generateAssetsTeaser: true,
|
||
generateAssetsSprites: true,
|
||
generateAssetsAnalyze: true,
|
||
enrichPostworkEnabled: true,
|
||
|
||
trainingRecognitionEnabled: true,
|
||
trainingDetectorEpochs: 60,
|
||
}
|
||
|
||
type Props = {
|
||
onAssetsGenerated?: () => void
|
||
}
|
||
|
||
function regeneratePhaseOrder(queue?: string, phase?: string) {
|
||
const q = String(queue || '').trim().toLowerCase()
|
||
const p = String(phase || '').trim().toLowerCase()
|
||
|
||
const key = `${q}:${p}`
|
||
|
||
switch (key) {
|
||
case 'postwork:meta':
|
||
return 0
|
||
case 'postwork:thumb':
|
||
return 1
|
||
case 'postwork:teaser':
|
||
return 2
|
||
case 'postwork:sprites':
|
||
return 3
|
||
case 'enrich:analyze':
|
||
return 4
|
||
default:
|
||
return 999
|
||
}
|
||
}
|
||
|
||
function shortTaskFilename(name?: string, max = 52) {
|
||
const s = String(name ?? '').trim()
|
||
if (!s) return ''
|
||
if (s.length <= max) return s
|
||
return '…' + s.slice(-(max - 1))
|
||
}
|
||
|
||
function regenerateTaskId(file: string) {
|
||
return `regenerate-assets:${file}`
|
||
}
|
||
|
||
function regenerateTaskTitle(_file: string) {
|
||
return 'Assets neu generieren'
|
||
}
|
||
|
||
function assetsPhaseLabel(label?: string, phase?: string) {
|
||
const direct = String(label ?? '').trim()
|
||
if (direct) return direct
|
||
|
||
switch (String(phase ?? '').trim().toLowerCase()) {
|
||
case 'meta':
|
||
return 'Meta'
|
||
case 'thumb':
|
||
return 'Vorschaubild'
|
||
case 'teaser':
|
||
return 'Teaser'
|
||
case 'sprites':
|
||
return 'Sprites'
|
||
case 'analyze':
|
||
return 'Analyse'
|
||
default:
|
||
return ''
|
||
}
|
||
}
|
||
|
||
function assetsTaskText(file?: string, label?: string, phase?: string) {
|
||
const fn = shortTaskFilename(file, 38)
|
||
const phaseText = assetsPhaseLabel(label, phase)
|
||
|
||
if (fn && phaseText) return `${fn} · ${phaseText}`
|
||
if (fn) return fn
|
||
return phaseText
|
||
}
|
||
|
||
function parseTaskTimeMs(v: unknown) {
|
||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||
|
||
const s = String(v ?? '').trim()
|
||
if (!s) return 0
|
||
|
||
const ms = Date.parse(s)
|
||
return Number.isFinite(ms) ? ms : 0
|
||
}
|
||
|
||
function assetTaskDomId(id: string) {
|
||
return `generate-assets:${id}`
|
||
}
|
||
|
||
function checkVideosTaskDomId(id: string) {
|
||
return `check-videos:${id}`
|
||
}
|
||
|
||
function cleanupTaskDomId(id: string) {
|
||
return `cleanup:${id}`
|
||
}
|
||
|
||
function isCancelledError(err?: string) {
|
||
const s = String(err ?? '').trim().toLowerCase()
|
||
return s === 'abgebrochen' || s === 'abbruch' || s === 'cancelled' || s === 'canceled'
|
||
}
|
||
|
||
function upsertTask(
|
||
setter: Dispatch<SetStateAction<TaskItem[]>>,
|
||
next: TaskItem
|
||
) {
|
||
setter((prev) => {
|
||
const idx = prev.findIndex((t) => t.id === next.id)
|
||
if (idx === -1) return [...prev, next]
|
||
const copy = [...prev]
|
||
copy[idx] = { ...copy[idx], ...next }
|
||
return copy
|
||
})
|
||
}
|
||
|
||
function mapAssetJobToTask(job: any): TaskItem {
|
||
const err = String(job?.error || '').trim()
|
||
let status: TaskItem['status'] = 'idle'
|
||
|
||
if (job?.queued) status = 'queued'
|
||
else if (job?.running) status = 'running'
|
||
else if (err) status = isCancelledError(err) ? 'cancelled' : 'error'
|
||
else if (job?.finishedAt) status = 'done'
|
||
|
||
return {
|
||
id: assetTaskDomId(String(job?.id || '')),
|
||
status,
|
||
title: 'Assets generieren',
|
||
text:
|
||
status === 'running'
|
||
? assetsTaskText(
|
||
String(job?.currentFile || '').trim(),
|
||
String(job?.currentLabel || '').trim(),
|
||
String(job?.currentPhase || '').trim()
|
||
)
|
||
: String(job?.text || ''),
|
||
done: Number(job?.done ?? 0),
|
||
total: Number(job?.total ?? 0),
|
||
err: status === 'error' ? err : undefined,
|
||
cancellable: status === 'queued' || status === 'running',
|
||
fading: false,
|
||
queuedAtMs: parseTaskTimeMs(job?.queuedAt),
|
||
startedAtMs: parseTaskTimeMs(job?.startedAt),
|
||
}
|
||
}
|
||
|
||
function mapCheckVideosJobToTask(job: any): TaskItem {
|
||
const err = String(job?.error || '').trim()
|
||
let status: TaskItem['status'] = 'idle'
|
||
|
||
if (job?.queued) status = 'queued'
|
||
else if (job?.running) status = 'running'
|
||
else if (err) status = isCancelledError(err) ? 'cancelled' : 'error'
|
||
else if (job?.finishedAt) status = 'done'
|
||
|
||
return {
|
||
id: checkVideosTaskDomId(String(job?.id || '')),
|
||
status,
|
||
title: 'Downloads prüfen',
|
||
text:
|
||
status === 'running'
|
||
? (String(job?.currentFile || '').trim()
|
||
? shortTaskFilename(String(job?.currentFile || '').trim(), 52)
|
||
: 'Prüfe Videos…')
|
||
: String(job?.text || ''),
|
||
done: Number(job?.done ?? 0),
|
||
total: Number(job?.total ?? 0),
|
||
err: status === 'error' ? err : undefined,
|
||
cancellable: status === 'queued' || status === 'running',
|
||
fading: false,
|
||
queuedAtMs: parseTaskTimeMs(job?.queuedAt),
|
||
startedAtMs: parseTaskTimeMs(job?.startedAt),
|
||
}
|
||
}
|
||
|
||
function mapCleanupJobToTask(job: any): TaskItem {
|
||
const rawId = String(job?.id || '')
|
||
const err = String(job?.error || '').trim()
|
||
let status: TaskItem['status'] = 'idle'
|
||
|
||
if (job?.queued) status = 'queued'
|
||
else if (job?.running) status = 'running'
|
||
else if (err) status = isCancelledError(err) ? 'cancelled' : 'error'
|
||
else if (job?.finishedAt) status = 'done'
|
||
|
||
return {
|
||
id: cleanupTaskDomId(rawId),
|
||
status,
|
||
title: cleanupTaskTitle(rawId),
|
||
text: String(job?.text || job?.currentFile || ''),
|
||
done: Number(job?.done ?? 0),
|
||
total: Number(job?.total ?? 0),
|
||
err: status === 'error' ? err : undefined,
|
||
cancellable: job?.cancellable !== false && (status === 'queued' || status === 'running'),
|
||
fading: false,
|
||
queuedAtMs: parseTaskTimeMs(job?.queuedAt),
|
||
startedAtMs: parseTaskTimeMs(job?.startedAt),
|
||
}
|
||
}
|
||
|
||
function cleanupTaskTitle(rawId: string) {
|
||
const id = String(rawId || '').trim().toLowerCase()
|
||
|
||
if (id.startsWith('cleanup-leftovers-startup')) return 'Startup-Cleanup'
|
||
if (id.startsWith('cleanup-leftovers-hourly')) return 'Stündlicher Cleanup'
|
||
if (id.startsWith('cleanup-leftovers')) return 'Record-Reste prüfen'
|
||
return 'Aufräumen'
|
||
}
|
||
|
||
function databaseBackupFilename(contentDisposition: string | null) {
|
||
const header = String(contentDisposition ?? '').trim()
|
||
if (!header) return 'nsfwapp-db-backup.dump'
|
||
|
||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)
|
||
if (encoded?.[1]) {
|
||
try {
|
||
return decodeURIComponent(encoded[1].trim().replace(/^"|"$/g, ''))
|
||
} catch {
|
||
return encoded[1].trim().replace(/^"|"$/g, '')
|
||
}
|
||
}
|
||
|
||
const plain = /filename="?([^";]+)"?/i.exec(header)
|
||
return plain?.[1]?.trim() || 'nsfwapp-db-backup.dump'
|
||
}
|
||
|
||
function formatDatabaseBackupBytes(bytes: unknown) {
|
||
const n = Number(bytes)
|
||
if (!Number.isFinite(n) || n <= 0) return ''
|
||
if (n < 1024) return `${Math.round(n)} B`
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||
}
|
||
|
||
const SETTINGS_RATING_STAR_PATH =
|
||
'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z'
|
||
|
||
function settingsRatingTone(stars: number) {
|
||
switch (stars) {
|
||
case 1:
|
||
return 'text-yellow-300 opacity-55'
|
||
case 2:
|
||
return 'text-yellow-300 opacity-65'
|
||
case 3:
|
||
return 'text-yellow-300 opacity-80'
|
||
case 4:
|
||
return 'text-yellow-300 opacity-95'
|
||
default:
|
||
return 'text-yellow-200 opacity-100'
|
||
}
|
||
}
|
||
|
||
function RatingThresholdStar({
|
||
stars,
|
||
filled,
|
||
}: {
|
||
stars: number
|
||
filled: boolean
|
||
}) {
|
||
return (
|
||
<span className="relative inline-grid h-7 w-7 shrink-0 place-items-center leading-none">
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
aria-hidden="true"
|
||
className={[
|
||
'h-7 w-7 shrink-0 transition duration-150',
|
||
filled
|
||
? `fill-current ${settingsRatingTone(stars)}`
|
||
: 'fill-transparent text-gray-300 dark:text-white/25',
|
||
].join(' ')}
|
||
style={{
|
||
filter: filled
|
||
? 'drop-shadow(0 1px 2px rgba(0,0,0,0.75)) drop-shadow(0 0 3px rgba(0,0,0,0.35))'
|
||
: 'drop-shadow(0 1px 1px rgba(0,0,0,0.22))',
|
||
}}
|
||
>
|
||
<path
|
||
d={SETTINGS_RATING_STAR_PATH}
|
||
stroke={filled ? 'rgba(0,0,0,0.75)' : 'currentColor'}
|
||
strokeWidth="1.1"
|
||
paintOrder="stroke fill"
|
||
/>
|
||
|
||
<text
|
||
x="10"
|
||
y="11.35"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fontSize="6.4"
|
||
fontWeight="900"
|
||
className={filled ? 'fill-white' : 'fill-gray-500 dark:fill-white/55'}
|
||
style={{
|
||
paintOrder: 'stroke fill',
|
||
stroke: filled ? 'rgba(0,0,0,0.9)' : 'transparent',
|
||
strokeWidth: filled ? 0.75 : 0,
|
||
}}
|
||
>
|
||
{stars}
|
||
</text>
|
||
</svg>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function RatingThresholdStars({
|
||
value,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
value: number | undefined
|
||
disabled?: boolean
|
||
onChange: (stars: number) => void
|
||
}) {
|
||
const selected = Math.max(1, Math.min(5, Math.round(Number(value ?? 3))))
|
||
|
||
return (
|
||
<div className="text-right">
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Sterne-Grenze"
|
||
className={[
|
||
'flex flex-wrap items-center justify-end gap-1',
|
||
disabled ? 'pointer-events-none opacity-50' : '',
|
||
].join(' ')}
|
||
>
|
||
{[1, 2, 3, 4, 5].map((stars) => {
|
||
const filled = stars <= selected
|
||
const checked = stars === selected
|
||
|
||
return (
|
||
<button
|
||
key={stars}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={checked}
|
||
disabled={disabled}
|
||
title={`Bis einschließlich ${stars} Stern${stars === 1 ? '' : 'e'} löschen`}
|
||
onClick={() => onChange(stars)}
|
||
className={[
|
||
'rounded-md p-0.5 transition',
|
||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950',
|
||
disabled
|
||
? 'cursor-not-allowed'
|
||
: 'hover:scale-110 active:scale-95',
|
||
].join(' ')}
|
||
>
|
||
<RatingThresholdStar stars={stars} filled={filled} />
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Löscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}.
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
||
const saveSuccessTimerRef = useRef<number | null>(null)
|
||
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
||
const dbRestoreInputRef = useRef<HTMLInputElement | null>(null)
|
||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||
const [msg, setMsg] = useState<string | null>(null)
|
||
const [err, setErr] = useState<string | null>(null)
|
||
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
|
||
const [aiServerStatus, setAiServerStatus] = useState<AIServerStatus | null>(null)
|
||
const [appLog, setAppLog] = useState<AppLog | null>(null)
|
||
const [appLogLoading, setAppLogLoading] = useState(false)
|
||
const [appLogOpen, setAppLogOpen] = useState(false)
|
||
const [dbModalOpen, setDbModalOpen] = useState(false)
|
||
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
||
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
||
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
||
const now = Date.now()
|
||
const saveSucceeded = saveSuccessUntilMs > now
|
||
const saveFailed = Boolean(err) && !saving && !saveSucceeded
|
||
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||
const databaseSettingsDirty =
|
||
currentDatabaseUrl !== loadedDatabaseUrl || Boolean((pendingDbPassword || '').trim())
|
||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy
|
||
|
||
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||
const [securityBusy, setSecurityBusy] = useState(false)
|
||
|
||
const [currentPassword, setCurrentPassword] = useState('')
|
||
const [newPassword, setNewPassword] = useState('')
|
||
const [newPasswordRepeat, setNewPasswordRepeat] = useState('')
|
||
|
||
const [disable2FAPassword, setDisable2FAPassword] = useState('')
|
||
const [disable2FACode, setDisable2FACode] = useState('')
|
||
|
||
const [disable2FAFieldError, setDisable2FAFieldError] = useState<{
|
||
password?: string
|
||
code?: string
|
||
} | null>(null)
|
||
|
||
const [passkeySupported, setPasskeySupported] = useState(false)
|
||
const [passkeyFeedback, setPasskeyFeedback] = useState<{
|
||
kind: 'loading' | 'success' | 'error'
|
||
text: string
|
||
} | null>(null)
|
||
|
||
type SaveUiState = 'idle' | 'saving' | 'success' | 'error'
|
||
const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle'
|
||
|
||
function scrollAppLogToBottom() {
|
||
window.requestAnimationFrame(() => {
|
||
const el = appLogPreRef.current
|
||
if (!el) return
|
||
|
||
el.scrollTop = el.scrollHeight
|
||
})
|
||
}
|
||
|
||
const saveButton = (() => {
|
||
if (saveUiState === 'saving') {
|
||
return {
|
||
text: 'Speichern…',
|
||
color: 'blue' as const,
|
||
icon: null, // Spinner kommt über isLoading
|
||
isLoading: true,
|
||
}
|
||
}
|
||
if (saveUiState === 'success') {
|
||
return {
|
||
text: 'Gespeichert',
|
||
color: 'emerald' as const,
|
||
icon: <CheckIcon className="size-4" />,
|
||
isLoading: false,
|
||
}
|
||
}
|
||
if (saveUiState === 'error') {
|
||
return {
|
||
text: 'Fehler',
|
||
color: 'red' as const,
|
||
icon: <XMarkIcon className="size-4" />,
|
||
isLoading: false,
|
||
}
|
||
}
|
||
return {
|
||
text: 'Speichern',
|
||
color: 'indigo' as const,
|
||
icon: null,
|
||
isLoading: false,
|
||
}
|
||
})()
|
||
|
||
const [assetTasks, setAssetTasks] = useState<TaskItem[]>([])
|
||
const [checkVideoTasks, setCheckVideoTasks] = useState<TaskItem[]>([])
|
||
const [cleanupTasks, setCleanupTasks] = useState<TaskItem[]>([])
|
||
const [regenerateAssetsTasks, setRegenerateAssetsTasks] = useState<TaskItem[]>([])
|
||
const reportedFinishedAssetTaskIdsRef = useRef<Set<string>>(new Set())
|
||
|
||
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
|
||
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
||
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
|
||
|
||
async function startAssetsTaskFromCurrentSwitches() {
|
||
setErr(null)
|
||
|
||
const payload = {
|
||
meta: !!value.generateAssetsMeta,
|
||
thumb: !!value.generateAssetsThumb,
|
||
teaser: !!value.generateAssetsTeaser,
|
||
sprites: !!value.generateAssetsSprites,
|
||
analyze: !!value.generateAssetsAnalyze,
|
||
}
|
||
|
||
if (!Object.values(payload).some(Boolean)) {
|
||
setErr('Mindestens eine Asset-Phase aktivieren.')
|
||
return null
|
||
}
|
||
|
||
const res = await fetch('/api/tasks/generate-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => '')
|
||
throw new Error(t || `HTTP ${res.status}`)
|
||
}
|
||
|
||
return await res.json().catch(() => null)
|
||
}
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (saveSuccessTimerRef.current != null) {
|
||
window.clearTimeout(saveSuccessTimerRef.current)
|
||
saveSuccessTimerRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
setPasskeySupported(
|
||
typeof window !== 'undefined' &&
|
||
typeof window.PublicKeyCredential !== 'undefined'
|
||
)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void loadAuthStatus()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
fetch('/api/settings', { cache: 'no-store' })
|
||
.then(async (r) => {
|
||
if (!r.ok) throw new Error(await r.text())
|
||
return r.json()
|
||
})
|
||
.then((data: RecorderSettings) => {
|
||
if (!alive) return
|
||
setValue({
|
||
databaseUrl: String((data as any).databaseUrl ?? ''),
|
||
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
|
||
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
||
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
|
||
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
|
||
|
||
|
||
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
|
||
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
|
||
autoStartAddedDownloads:
|
||
(data as any).autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
|
||
enableConcurrentDownloadsLimit:
|
||
(data as any).enableConcurrentDownloadsLimit ?? DEFAULTS.enableConcurrentDownloadsLimit,
|
||
maxConcurrentDownloads:
|
||
(data as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads,
|
||
|
||
useProviderApis:
|
||
(data as any).useProviderApis ??
|
||
Boolean((data as any).useChaturbateApi || (data as any).useMyFreeCamsApi),
|
||
|
||
useChaturbateApi:
|
||
(data as any).useChaturbateApi ?? DEFAULTS.useChaturbateApi,
|
||
|
||
useMyFreeCamsApi:
|
||
(data as any).useMyFreeCamsApi ?? DEFAULTS.useMyFreeCamsApi,
|
||
autoDeleteSmallDownloads: data.autoDeleteSmallDownloads ?? DEFAULTS.autoDeleteSmallDownloads,
|
||
autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB,
|
||
autoDeleteSmallDownloadsKeepFavorites:
|
||
(data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites,
|
||
autoDeleteLowRatedDownloads:
|
||
(data as any).autoDeleteLowRatedDownloads ?? DEFAULTS.autoDeleteLowRatedDownloads,
|
||
autoDeleteLowRatedDownloadsMaxStars:
|
||
(data as any).autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars,
|
||
blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews,
|
||
teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback,
|
||
teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio,
|
||
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
|
||
enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications,
|
||
generateAssetsMeta: (data as any).generateAssetsMeta ?? DEFAULTS.generateAssetsMeta,
|
||
generateAssetsThumb: (data as any).generateAssetsThumb ?? DEFAULTS.generateAssetsThumb,
|
||
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
|
||
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
|
||
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
|
||
enrichPostworkEnabled: (data as any).enrichPostworkEnabled ?? DEFAULTS.enrichPostworkEnabled,
|
||
|
||
trainingRecognitionEnabled:
|
||
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
|
||
trainingDetectorEpochs:
|
||
(data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs,
|
||
})
|
||
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
|
||
})
|
||
.catch(() => {
|
||
// backend evtl. noch alt -> defaults lassen
|
||
})
|
||
return () => {
|
||
alive = false
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
|
||
const loadTasksStatus = async () => {
|
||
try {
|
||
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
if (!alive || !data) return
|
||
|
||
const assetJobs = Array.isArray(data?.generateAssetsJobs) ? data.generateAssetsJobs : []
|
||
setAssetTasks(
|
||
assetJobs
|
||
.map(mapAssetJobToTask)
|
||
.filter((t: TaskItem) => t.status !== 'idle')
|
||
)
|
||
|
||
const regenJobs = Array.isArray(data?.regenerateAssetsJobs)
|
||
? data.regenerateAssetsJobs
|
||
: []
|
||
|
||
setRegenerateAssetsTasks((prev) => {
|
||
const prevById = new Map(prev.map((t: TaskItem) => [t.id, t] as const))
|
||
|
||
const nextTasks: TaskItem[] = regenJobs
|
||
.map((job: any): TaskItem | null => {
|
||
const file = String(job?.file || '').trim()
|
||
if (!file) return null
|
||
|
||
const id = regenerateTaskId(file)
|
||
const prevTask = prevById.get(id)
|
||
|
||
const state = String(job?.state || '').trim().toLowerCase()
|
||
const error = String(job?.error || '').trim()
|
||
|
||
const cancelled =
|
||
state === 'cancelled' ||
|
||
error.toLowerCase() === 'abgebrochen' ||
|
||
error.toLowerCase() === 'abbruch' ||
|
||
error.toLowerCase() === 'cancelled' ||
|
||
error.toLowerCase() === 'canceled'
|
||
|
||
let status: TaskItem['status'] = 'idle'
|
||
|
||
if (state === 'queued') status = 'queued'
|
||
else if (state === 'running') status = 'running'
|
||
else if (state === 'done') status = 'done'
|
||
else if (cancelled) status = 'cancelled'
|
||
else if (state === 'error') status = 'error'
|
||
|
||
const phases = Array.isArray(job?.phases) ? job.phases : []
|
||
|
||
const relevantPhases = phases
|
||
.filter((p: any) => {
|
||
const q = String(p?.queue || '').toLowerCase()
|
||
const ph = String(p?.phase || '').toLowerCase()
|
||
return (
|
||
(q === 'postwork' && ['meta', 'thumb', 'teaser', 'sprites'].includes(ph)) ||
|
||
(q === 'enrich' && ph === 'analyze')
|
||
)
|
||
})
|
||
.sort((a: any, b: any) => {
|
||
return regeneratePhaseOrder(a?.queue, a?.phase) - regeneratePhaseOrder(b?.queue, b?.phase)
|
||
})
|
||
|
||
const runningPhase = relevantPhases.find(
|
||
(p: any) => String(p?.state || '').toLowerCase() === 'running'
|
||
)
|
||
|
||
const queuedPhase = relevantPhases.find(
|
||
(p: any) => String(p?.state || '').toLowerCase() === 'queued'
|
||
)
|
||
|
||
const activePhase = runningPhase ?? queuedPhase
|
||
|
||
const phaseLabel = assetsPhaseLabel(
|
||
String(activePhase?.label || '').trim(),
|
||
String(activePhase?.phase || '').trim()
|
||
)
|
||
|
||
const total = relevantPhases.length || 5
|
||
const done = relevantPhases.filter(
|
||
(p: any) => String(p?.state || '').toLowerCase() === 'done'
|
||
).length
|
||
|
||
const stableQueuedAtMs =
|
||
prevTask?.queuedAtMs ||
|
||
Number(job?.enqueuedAt ?? 0) ||
|
||
Number(job?.startedAt ?? 0) ||
|
||
Date.now()
|
||
|
||
const stableStartedAtMs =
|
||
Number(job?.startedAt ?? 0) ||
|
||
prevTask?.startedAtMs ||
|
||
0
|
||
|
||
const text =
|
||
status === 'running' || status === 'queued'
|
||
? phaseLabel
|
||
? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
|
||
: shortTaskFilename(file, 52)
|
||
: shortTaskFilename(file, 52)
|
||
|
||
return {
|
||
id,
|
||
status,
|
||
title: regenerateTaskTitle(file),
|
||
text,
|
||
done: status === 'done' ? total : done,
|
||
total,
|
||
err: status === 'error' ? error || 'Fehler beim Regenerieren.' : undefined,
|
||
cancellable: status === 'queued' || status === 'running',
|
||
fading: prevTask?.fading ?? false,
|
||
queuedAtMs: stableQueuedAtMs,
|
||
startedAtMs: stableStartedAtMs,
|
||
}
|
||
})
|
||
.filter(Boolean) as TaskItem[]
|
||
|
||
const nextById = new Map(nextTasks.map((t: TaskItem) => [t.id, t] as const))
|
||
const merged: TaskItem[] = []
|
||
|
||
for (const next of nextTasks) {
|
||
const prevTask = prevById.get(next.id)
|
||
merged.push(prevTask?.fading ? { ...next, fading: true } : next)
|
||
}
|
||
|
||
for (const prevTask of prev) {
|
||
if (nextById.has(prevTask.id)) continue
|
||
if (prevTask.status === 'running' || prevTask.status === 'queued') continue
|
||
|
||
if (prevTask.fading) {
|
||
merged.push(prevTask)
|
||
continue
|
||
}
|
||
|
||
merged.push({ ...prevTask, fading: true })
|
||
|
||
window.setTimeout(() => {
|
||
setRegenerateAssetsTasks((cur: TaskItem[]) =>
|
||
cur.filter((t: TaskItem) => t.id !== prevTask.id)
|
||
)
|
||
}, 500)
|
||
}
|
||
|
||
return merged
|
||
})
|
||
|
||
const checkVideoJobs = Array.isArray(data?.checkVideosJobs) ? data.checkVideosJobs : []
|
||
setCheckVideoTasks(
|
||
checkVideoJobs
|
||
.map(mapCheckVideosJobToTask)
|
||
.filter((t: TaskItem) => t.status !== 'idle')
|
||
)
|
||
|
||
const cleanupJobs = Array.isArray(data?.cleanupJobs) ? data.cleanupJobs : []
|
||
setCleanupTasks(
|
||
cleanupJobs
|
||
.map(mapCleanupJobToTask)
|
||
.filter((t: TaskItem) => t.status !== 'idle')
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
void loadTasksStatus()
|
||
const timer = window.setInterval(loadTasksStatus, 1500)
|
||
|
||
return () => {
|
||
alive = false
|
||
window.clearInterval(timer)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
for (const task of assetTasks) {
|
||
if (task.status === 'done') {
|
||
if (!reportedFinishedAssetTaskIdsRef.current.has(task.id)) {
|
||
reportedFinishedAssetTaskIdsRef.current.add(task.id)
|
||
onAssetsGenerated?.()
|
||
}
|
||
} else if (task.status === 'queued' || task.status === 'running') {
|
||
reportedFinishedAssetTaskIdsRef.current.delete(task.id)
|
||
}
|
||
}
|
||
}, [assetTasks, onAssetsGenerated])
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
const load = async () => {
|
||
try {
|
||
const r = await fetch('/api/status/disk', { cache: 'no-store' })
|
||
if (!r.ok) return
|
||
const data = (await r.json()) as DiskStatus
|
||
if (alive) setDiskStatus(data)
|
||
} catch {
|
||
// ignorieren
|
||
}
|
||
}
|
||
|
||
load()
|
||
const t = window.setInterval(load, 1000) // jede Sekunde aktualisieren
|
||
return () => {
|
||
alive = false
|
||
window.clearInterval(t)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
|
||
const load = async () => {
|
||
try {
|
||
const res = await fetch('/api/status/ai-server', { cache: 'no-store' })
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (!alive) return
|
||
|
||
if (!res.ok || !data) {
|
||
setAiServerStatus({
|
||
ok: false,
|
||
running: false,
|
||
error: `HTTP ${res.status}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
setAiServerStatus({
|
||
ok: Boolean(data.ok),
|
||
running: Boolean(data.running),
|
||
url: String(data.url || ''),
|
||
statusCode: Number(data.statusCode || 0),
|
||
error: String(data.error || ''),
|
||
})
|
||
} catch (e) {
|
||
if (!alive) return
|
||
|
||
setAiServerStatus({
|
||
ok: false,
|
||
running: false,
|
||
error: e instanceof Error ? e.message : String(e),
|
||
})
|
||
}
|
||
}
|
||
|
||
void load()
|
||
const timer = window.setInterval(load, 5000)
|
||
|
||
return () => {
|
||
alive = false
|
||
window.clearInterval(timer)
|
||
}
|
||
}, [])
|
||
|
||
async function loadAppLog() {
|
||
setAppLogLoading(true)
|
||
|
||
try {
|
||
const res = await fetch('/api/status/log?maxBytes=131072', {
|
||
cache: 'no-store',
|
||
})
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (!res.ok || !data) {
|
||
setAppLog({
|
||
ok: false,
|
||
log: '',
|
||
error: `HTTP ${res.status}`,
|
||
})
|
||
return
|
||
}
|
||
|
||
setAppLog({
|
||
ok: Boolean(data.ok),
|
||
exists: Boolean(data.exists),
|
||
path: String(data.path || ''),
|
||
sizeBytes: Number(data.sizeBytes || 0),
|
||
log: String(data.log || ''),
|
||
error: String(data.error || ''),
|
||
})
|
||
} catch (e) {
|
||
setAppLog({
|
||
ok: false,
|
||
log: '',
|
||
error: e instanceof Error ? e.message : String(e),
|
||
})
|
||
} finally {
|
||
setAppLogLoading(false)
|
||
scrollAppLogToBottom()
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!appLogOpen) return
|
||
void loadAppLog()
|
||
}, [appLogOpen])
|
||
|
||
useEffect(() => {
|
||
if (!appLogOpen) return
|
||
|
||
scrollAppLogToBottom()
|
||
}, [appLogOpen, appLog?.log, appLogLoading])
|
||
|
||
async function browse(target: 'record' | 'done' | 'ffmpeg') {
|
||
setErr(null)
|
||
setMsg(null)
|
||
setBrowsing(target)
|
||
|
||
try {
|
||
window.focus()
|
||
const res = await fetch(`/api/settings/browse?target=${target}`, { cache: 'no-store' })
|
||
if (res.status === 204) return // user cancelled
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => '')
|
||
throw new Error(t || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = (await res.json()) as { path?: string }
|
||
const p = (data.path ?? '').trim()
|
||
if (!p) return
|
||
|
||
setValue((v) => {
|
||
if (target === 'record') return { ...v, recordDir: p }
|
||
if (target === 'done') return { ...v, doneDir: p }
|
||
return { ...v, ffmpegPath: p }
|
||
})
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? String(e))
|
||
} finally {
|
||
setBrowsing(null)
|
||
}
|
||
}
|
||
|
||
async function downloadDatabaseBackup() {
|
||
setDbMaintenanceMsg(null)
|
||
setDbMaintenanceErr(null)
|
||
|
||
if (!loadedDatabaseUrl) {
|
||
setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.')
|
||
return
|
||
}
|
||
if (databaseSettingsDirty) {
|
||
setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.')
|
||
return
|
||
}
|
||
|
||
setDbBackupBusy(true)
|
||
try {
|
||
const res = await fetch('/api/settings/database/backup', { cache: 'no-store' })
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => '')
|
||
throw new Error(t || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const blob = await res.blob()
|
||
const filename = databaseBackupFilename(res.headers.get('Content-Disposition'))
|
||
const href = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = href
|
||
a.download = filename
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
window.URL.revokeObjectURL(href)
|
||
|
||
const size = formatDatabaseBackupBytes(blob.size)
|
||
setDbMaintenanceMsg(size ? `Backup heruntergeladen (${size}).` : 'Backup heruntergeladen.')
|
||
} catch (e: any) {
|
||
setDbMaintenanceErr(e?.message ?? String(e))
|
||
} finally {
|
||
setDbBackupBusy(false)
|
||
}
|
||
}
|
||
|
||
function chooseDatabaseRestoreFile() {
|
||
setDbMaintenanceMsg(null)
|
||
setDbMaintenanceErr(null)
|
||
dbRestoreInputRef.current?.click()
|
||
}
|
||
|
||
async function restoreDatabaseBackup(file?: File | null) {
|
||
if (!file) return
|
||
|
||
setDbMaintenanceMsg(null)
|
||
setDbMaintenanceErr(null)
|
||
|
||
if (!loadedDatabaseUrl) {
|
||
setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.')
|
||
return
|
||
}
|
||
if (databaseSettingsDirty) {
|
||
setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.')
|
||
return
|
||
}
|
||
|
||
const ok = window.confirm(
|
||
`Datenbank-Backup "${file.name}" wiederherstellen?\n\n` +
|
||
'Das ueberschreibt die aktuelle Datenbank. Dieser Schritt kann nicht automatisch rueckgaengig gemacht werden.'
|
||
)
|
||
if (!ok) return
|
||
|
||
setDbRestoreBusy(true)
|
||
try {
|
||
const form = new FormData()
|
||
form.append('backup', file)
|
||
|
||
const res = await fetch('/api/settings/database/restore', {
|
||
method: 'POST',
|
||
body: form,
|
||
})
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => '')
|
||
throw new Error(t || `HTTP ${res.status}`)
|
||
}
|
||
|
||
const data = await res.json().catch(() => null)
|
||
const size = formatDatabaseBackupBytes(data?.bytes ?? file.size)
|
||
setDbMaintenanceMsg(size ? `Restore abgeschlossen (${size}).` : 'Restore abgeschlossen.')
|
||
|
||
window.dispatchEvent(new Event('models-changed'))
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-db-changed', {
|
||
detail: { databaseUrl: loadedDatabaseUrl, restored: true },
|
||
})
|
||
)
|
||
} catch (e: any) {
|
||
setDbMaintenanceErr(e?.message ?? String(e))
|
||
} finally {
|
||
setDbRestoreBusy(false)
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
setErr(null)
|
||
setMsg(null)
|
||
|
||
const recordDir = value.recordDir.trim()
|
||
const doneDir = value.doneDir.trim()
|
||
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
||
const databaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||
const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim())
|
||
|
||
if (!recordDir || !doneDir) {
|
||
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
||
return
|
||
}
|
||
|
||
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
|
||
const autoAddToDownloadList = !!value.autoAddToDownloadList
|
||
const autoStartAddedDownloads = !!value.autoStartAddedDownloads
|
||
const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit
|
||
const maxConcurrentDownloads = Math.max(
|
||
1,
|
||
Math.min(100, Math.floor(Number((value as any).maxConcurrentDownloads ?? DEFAULTS.maxConcurrentDownloads ?? 20)))
|
||
)
|
||
|
||
const useProviderApis = !!value.useProviderApis
|
||
const useChaturbateApi = useProviderApis && !!value.useChaturbateApi
|
||
const useMyFreeCamsApi = useProviderApis && !!value.useMyFreeCamsApi
|
||
const autoDeleteSmallDownloads = !!value.autoDeleteSmallDownloads
|
||
const autoDeleteSmallDownloadsBelowMB = Math.max(
|
||
0,
|
||
Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB)))
|
||
)
|
||
const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites
|
||
const autoDeleteLowRatedDownloads = !!value.autoDeleteLowRatedDownloads
|
||
const autoDeleteLowRatedDownloadsMaxStars = Math.max(
|
||
1,
|
||
Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3)))
|
||
)
|
||
const blurPreviews = !!value.blurPreviews
|
||
const teaserPlayback =
|
||
value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover'
|
||
? value.teaserPlayback
|
||
: DEFAULTS.teaserPlayback
|
||
const teaserAudio = !!value.teaserAudio
|
||
const lowDiskPauseBelowGB = Math.max(1, Math.floor(Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB)))
|
||
const enableNotifications = !!value.enableNotifications
|
||
const generateAssetsMeta = !!value.generateAssetsMeta
|
||
const generateAssetsThumb = !!value.generateAssetsThumb
|
||
const generateAssetsTeaser = !!value.generateAssetsTeaser
|
||
const generateAssetsSprites = !!value.generateAssetsSprites
|
||
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
|
||
const enrichPostworkEnabled = !!value.enrichPostworkEnabled
|
||
|
||
const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled
|
||
const trainingDetectorEpochs = Math.max(
|
||
1,
|
||
Math.min(300, Math.floor(Number(value.trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs ?? 60)))
|
||
)
|
||
|
||
setSaving(true)
|
||
try {
|
||
const res = await fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
databaseUrl,
|
||
dbPassword: pendingDbPassword || undefined,
|
||
recordDir,
|
||
doneDir,
|
||
ffmpegPath,
|
||
autoAddToDownloadList,
|
||
autoStartAddedDownloads,
|
||
enableConcurrentDownloadsLimit,
|
||
maxConcurrentDownloads,
|
||
useProviderApis,
|
||
useChaturbateApi,
|
||
useMyFreeCamsApi,
|
||
autoDeleteSmallDownloads,
|
||
autoDeleteSmallDownloadsBelowMB,
|
||
autoDeleteSmallDownloadsKeepFavorites,
|
||
autoDeleteLowRatedDownloads,
|
||
autoDeleteLowRatedDownloadsMaxStars,
|
||
blurPreviews,
|
||
teaserPlayback,
|
||
teaserAudio,
|
||
lowDiskPauseBelowGB,
|
||
enableNotifications,
|
||
generateAssetsMeta,
|
||
generateAssetsThumb,
|
||
generateAssetsTeaser,
|
||
generateAssetsSprites,
|
||
generateAssetsAnalyze,
|
||
enrichPostworkEnabled,
|
||
trainingRecognitionEnabled,
|
||
trainingDetectorEpochs,
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => '')
|
||
throw new Error(t || `HTTP ${res.status}`)
|
||
}
|
||
// Button-Label: "Gespeichert" für 2.5s
|
||
const until = Date.now() + 2500
|
||
setSaveSuccessUntilMs(until)
|
||
|
||
if (saveSuccessTimerRef.current != null) {
|
||
window.clearTimeout(saveSuccessTimerRef.current)
|
||
}
|
||
saveSuccessTimerRef.current = window.setTimeout(() => {
|
||
setSaveSuccessUntilMs(0)
|
||
saveSuccessTimerRef.current = null
|
||
}, 2500)
|
||
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||
|
||
const databaseUrlChanged =
|
||
databaseUrl !== loadedDatabaseUrl || hadPendingDbPassword
|
||
|
||
if (databaseUrlChanged) {
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-db-changed', {
|
||
detail: { databaseUrl },
|
||
})
|
||
)
|
||
setLoadedDatabaseUrl(databaseUrl)
|
||
setPendingDbPassword('')
|
||
}
|
||
} catch (e: any) {
|
||
setSaveSuccessUntilMs(0)
|
||
if (saveSuccessTimerRef.current != null) {
|
||
window.clearTimeout(saveSuccessTimerRef.current)
|
||
saveSuccessTimerRef.current = null
|
||
}
|
||
setErr(e?.message ?? String(e))
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
function fadeOutRegenerateTask(file: string, delayMs = 3000, fadeMs = 500) {
|
||
const id = regenerateTaskId(file)
|
||
|
||
window.setTimeout(() => {
|
||
setRegenerateAssetsTasks((prev) =>
|
||
prev.map((t) => (t.id === id ? { ...t, fading: true } : t))
|
||
)
|
||
|
||
window.setTimeout(() => {
|
||
setRegenerateAssetsTasks((prev) => prev.filter((t) => t.id !== id))
|
||
}, fadeMs)
|
||
}, delayMs)
|
||
}
|
||
|
||
async function cleanupSmallDone() {
|
||
setErr(null)
|
||
|
||
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
|
||
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
|
||
const deleteLowRated = !!value.autoDeleteLowRatedDownloads
|
||
const maxStars = Math.max(
|
||
1,
|
||
Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3)))
|
||
)
|
||
|
||
if (!doneDir) {
|
||
setErr('doneDir ist leer.')
|
||
return null
|
||
}
|
||
if ((!mb || mb <= 0) && !deleteLowRated) {
|
||
setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert – es würde nichts gelöscht.')
|
||
return null
|
||
}
|
||
|
||
const ratingLine = deleteLowRated
|
||
? `• Rating ≤ ${maxStars} Stern${maxStars === 1 ? '' : 'e'}\n`
|
||
: ''
|
||
|
||
const sizeLine = mb > 0
|
||
? `• Dateien < ${mb} MB in "${doneDir}"\n`
|
||
: ''
|
||
|
||
const ok = window.confirm(
|
||
`Aufräumen:\n` +
|
||
sizeLine +
|
||
ratingLine +
|
||
`• Entfernt defekte Video-Dateien\n` +
|
||
`• Löscht Record-Reste und verwaiste Assets\n\n` +
|
||
`Fortfahren?`
|
||
)
|
||
if (!ok) return null
|
||
|
||
const res = await fetch('/api/settings/cleanup', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const t = await res.text().catch(() => '')
|
||
throw new Error(t || `HTTP ${res.status}`)
|
||
}
|
||
|
||
return await res.json().catch(() => null)
|
||
}
|
||
|
||
async function cancelAssetsTask(jobId: string) {
|
||
try {
|
||
await fetch(`/api/tasks/generate-assets?id=${encodeURIComponent(jobId)}`, {
|
||
method: 'DELETE',
|
||
cache: 'no-store' as any,
|
||
})
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
async function cancelRegenerateAssetsTask(file: string) {
|
||
const cleanFile = String(file || '').trim()
|
||
if (!cleanFile) return
|
||
|
||
setRegenerateAssetsTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === regenerateTaskId(cleanFile)
|
||
? {
|
||
...t,
|
||
status: 'cancelled',
|
||
title: regenerateTaskTitle(cleanFile),
|
||
text: shortTaskFilename(cleanFile, 52),
|
||
err: undefined,
|
||
cancellable: false,
|
||
fading: false,
|
||
}
|
||
: t
|
||
)
|
||
)
|
||
|
||
try {
|
||
await fetch('/api/record/regenerate-assets', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store' as any,
|
||
body: JSON.stringify({ file: cleanFile }),
|
||
})
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
fadeOutRegenerateTask(cleanFile)
|
||
}
|
||
|
||
async function cancelCheckVideosTask(jobId: string) {
|
||
try {
|
||
await fetch(`/api/tasks/check-videos?id=${encodeURIComponent(jobId)}`, {
|
||
method: 'DELETE',
|
||
cache: 'no-store' as any,
|
||
})
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
async function cancelCleanupTask(jobId: string) {
|
||
try {
|
||
await fetch(`/api/settings/cleanup?id=${encodeURIComponent(jobId)}`, {
|
||
method: 'DELETE',
|
||
cache: 'no-store' as any,
|
||
})
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const visibleTasks: TaskItem[] = [
|
||
...assetTasks,
|
||
...regenerateAssetsTasks,
|
||
...checkVideoTasks,
|
||
...cleanupTasks,
|
||
].filter((t: TaskItem) => t.status !== 'idle')
|
||
|
||
async function loadAuthStatus() {
|
||
try {
|
||
const res = await fetch('/api/auth/me', { cache: 'no-store' })
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
if (!data) return
|
||
|
||
setAuthStatus({
|
||
authenticated: Boolean(data.authenticated),
|
||
totpEnabled: Boolean(data.totpEnabled),
|
||
totpConfigured: Boolean(data.totpConfigured),
|
||
passkeyConfigured: Boolean(data.passkeyConfigured),
|
||
passkeyCount: Number(data.passkeyCount ?? 0),
|
||
totpDevice: data.totpDevice ?? null,
|
||
passkeys: Array.isArray(data.passkeys) ? data.passkeys : [],
|
||
})
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
async function changePassword() {
|
||
setErr(null)
|
||
setMsg(null)
|
||
|
||
if (!currentPassword.trim()) {
|
||
setErr('Bitte aktuelles Passwort eingeben.')
|
||
return
|
||
}
|
||
|
||
if (newPassword.length < 8) {
|
||
setErr('Das neue Passwort muss mindestens 8 Zeichen lang sein.')
|
||
return
|
||
}
|
||
|
||
if (newPassword !== newPasswordRepeat) {
|
||
setErr('Die neuen Passwörter stimmen nicht überein.')
|
||
return
|
||
}
|
||
|
||
setSecurityBusy(true)
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/password/change', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify({
|
||
currentPassword,
|
||
newPassword,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
setCurrentPassword('')
|
||
setNewPassword('')
|
||
setNewPasswordRepeat('')
|
||
setMsg('Passwort wurde geändert.')
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? String(e))
|
||
} finally {
|
||
setSecurityBusy(false)
|
||
}
|
||
}
|
||
|
||
async function disable2FA() {
|
||
setErr(null)
|
||
setMsg(null)
|
||
setDisable2FAFieldError(null)
|
||
|
||
const password = disable2FAPassword.trim()
|
||
const code = disable2FACode.trim()
|
||
|
||
const nextFieldError: {
|
||
password?: string
|
||
code?: string
|
||
} = {}
|
||
|
||
if (!password) {
|
||
nextFieldError.password = 'Gib dein aktuelles Passwort ein, um 2FA zu deaktivieren.'
|
||
}
|
||
|
||
if (authStatus?.totpEnabled && !/^\d{6}$/.test(code)) {
|
||
nextFieldError.code = 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.'
|
||
}
|
||
|
||
if (nextFieldError.password || nextFieldError.code) {
|
||
setDisable2FAFieldError(nextFieldError)
|
||
return
|
||
}
|
||
|
||
const ok = window.confirm('2FA wirklich deaktivieren? Dein Login ist danach weniger geschützt.')
|
||
if (!ok) return
|
||
|
||
setSecurityBusy(true)
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/2fa/disable', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify({
|
||
currentPassword: password,
|
||
code,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
setDisable2FAPassword('')
|
||
setDisable2FACode('')
|
||
setDisable2FAFieldError(null)
|
||
setMsg('2FA wurde deaktiviert.')
|
||
await loadAuthStatus()
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? String(e))
|
||
} finally {
|
||
setSecurityBusy(false)
|
||
}
|
||
}
|
||
|
||
function passkeyErrorMessage(e: any) {
|
||
const raw = String(e?.message ?? e ?? '').trim()
|
||
|
||
if (!raw) return 'Passkey konnte nicht hinzugefügt werden.'
|
||
|
||
if (raw.includes('passkey registration verify failed')) {
|
||
return (
|
||
raw +
|
||
'\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.'
|
||
)
|
||
}
|
||
|
||
if (
|
||
raw.includes('NotAllowedError') ||
|
||
raw.toLowerCase().includes('not allowed') ||
|
||
raw.toLowerCase().includes('aborted')
|
||
) {
|
||
return 'Passkey-Einrichtung wurde abgebrochen oder vom Browser/Authenticator nicht erlaubt.'
|
||
}
|
||
|
||
if (
|
||
raw.includes('SecurityError') ||
|
||
raw.toLowerCase().includes('relying party') ||
|
||
raw.toLowerCase().includes('rp id') ||
|
||
raw.toLowerCase().includes('origin')
|
||
) {
|
||
return (
|
||
raw +
|
||
'\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.'
|
||
)
|
||
}
|
||
|
||
if (raw.includes('challenge expired')) {
|
||
return 'Die Passkey-Challenge ist abgelaufen. Bitte erneut versuchen.'
|
||
}
|
||
|
||
return raw
|
||
}
|
||
|
||
async function registerSettingsPasskey() {
|
||
if (securityBusy) return
|
||
|
||
setErr(null)
|
||
setMsg(null)
|
||
setPasskeyFeedback({
|
||
kind: 'loading',
|
||
text: 'Starte Passkey-Einrichtung…',
|
||
})
|
||
setSecurityBusy(true)
|
||
|
||
try {
|
||
setPasskeyFeedback({
|
||
kind: 'loading',
|
||
text: 'Hole WebAuthn-Challenge vom Backend…',
|
||
})
|
||
|
||
const optionsRes = await fetch('/api/auth/passkey/register/options', {
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
})
|
||
|
||
if (!optionsRes.ok) {
|
||
const text = await optionsRes.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${optionsRes.status}`)
|
||
}
|
||
|
||
const options = await optionsRes.json()
|
||
|
||
setPasskeyFeedback({
|
||
kind: 'loading',
|
||
text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.',
|
||
})
|
||
|
||
const credential = await startRegistration({
|
||
optionsJSON: unwrapWebAuthnOptions(options),
|
||
})
|
||
|
||
setPasskeyFeedback({
|
||
kind: 'loading',
|
||
text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend…',
|
||
})
|
||
|
||
const verifyRes = await fetch('/api/auth/passkey/register/verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify(credential),
|
||
})
|
||
|
||
if (!verifyRes.ok) {
|
||
const text = await verifyRes.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${verifyRes.status}`)
|
||
}
|
||
|
||
setMsg('Passkey wurde hinzugefügt.')
|
||
setPasskeyFeedback({
|
||
kind: 'success',
|
||
text: 'Passkey wurde erfolgreich hinzugefügt.',
|
||
})
|
||
await loadAuthStatus()
|
||
} catch (e: any) {
|
||
const message = passkeyErrorMessage(e)
|
||
|
||
setErr(message)
|
||
setPasskeyFeedback({
|
||
kind: 'error',
|
||
text: message,
|
||
})
|
||
} finally {
|
||
setSecurityBusy(false)
|
||
}
|
||
}
|
||
|
||
async function deletePasskey(passkey: AuthDevice) {
|
||
setErr(null)
|
||
setMsg(null)
|
||
|
||
const credentialId = String(passkey?.id ?? '').trim()
|
||
if (!credentialId) {
|
||
setErr('Passkey kann nicht gelöscht werden: Credential-ID fehlt.')
|
||
return
|
||
}
|
||
|
||
const label = String(passkey?.label || 'diesen Passkey').trim()
|
||
|
||
const ok = window.confirm(
|
||
`"${label}" wirklich löschen? Anmeldung mit diesem Passkey ist danach nicht mehr möglich.`
|
||
)
|
||
if (!ok) return
|
||
|
||
setSecurityBusy(true)
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/passkey/delete', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify({
|
||
credentialId,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
setMsg('Passkey wurde gelöscht.')
|
||
await loadAuthStatus()
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? String(e))
|
||
} finally {
|
||
setSecurityBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card
|
||
header={
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||
Einstellungen
|
||
</div>
|
||
|
||
<span
|
||
className={[
|
||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
|
||
aiServerStatus === null
|
||
? 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
|
||
: aiServerStatus.running
|
||
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
|
||
: 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30',
|
||
].join(' ')}
|
||
title={
|
||
aiServerStatus === null
|
||
? 'AI Server Status wird geladen…'
|
||
: aiServerStatus.running
|
||
? `AI Server läuft${aiServerStatus.url ? `: ${aiServerStatus.url}` : ''}`
|
||
: `AI Server offline${aiServerStatus.error ? `: ${aiServerStatus.error}` : ''}`
|
||
}
|
||
>
|
||
<span
|
||
className={[
|
||
'h-1.5 w-1.5 rounded-full',
|
||
aiServerStatus === null
|
||
? 'bg-gray-400'
|
||
: aiServerStatus.running
|
||
? 'bg-emerald-500'
|
||
: 'bg-red-500',
|
||
].join(' ')}
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
{aiServerStatus === null
|
||
? 'AI prüft…'
|
||
: aiServerStatus.running
|
||
? 'AI online'
|
||
: 'AI offline'}
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
||
Recorder-Konfiguration, Automatisierung und Tasks.
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rechts: Alerts + Button */}
|
||
<div className="flex items-start gap-2">
|
||
<Button
|
||
variant="primary"
|
||
color={saveButton.color}
|
||
onClick={save}
|
||
disabled={saving}
|
||
className="shrink-0"
|
||
size="md"
|
||
isLoading={saveButton.isLoading}
|
||
leadingIcon={saveButton.icon}
|
||
>
|
||
{saveButton.text}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
}
|
||
grayBody
|
||
>
|
||
<div className="space-y-4">
|
||
<TaskList
|
||
tasks={visibleTasks}
|
||
onCancel={(id: string) => {
|
||
if (id.startsWith('generate-assets:')) {
|
||
const jobId = id.slice('generate-assets:'.length)
|
||
void cancelAssetsTask(jobId)
|
||
return
|
||
}
|
||
|
||
if (id.startsWith('regenerate-assets:')) {
|
||
const file = id.slice('regenerate-assets:'.length)
|
||
void cancelRegenerateAssetsTask(file)
|
||
return
|
||
}
|
||
|
||
if (id.startsWith('check-videos:')) {
|
||
const jobId = id.slice('check-videos:'.length)
|
||
void cancelCheckVideosTask(jobId)
|
||
return
|
||
}
|
||
|
||
if (id.startsWith('cleanup:')) {
|
||
const jobId = id.slice('cleanup:'.length)
|
||
void cancelCleanupTask(jobId)
|
||
return
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* Alerts (mobile) */}
|
||
{saveUiState !== 'success' ? (
|
||
<div className="sm:hidden">
|
||
{err && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||
{err}
|
||
</div>
|
||
)}
|
||
{msg && (
|
||
<div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200">
|
||
{msg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Aufgaben */}
|
||
<SettingsSection
|
||
id="tasks"
|
||
title="Aufgaben"
|
||
description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung."
|
||
icon="⚙️"
|
||
defaultOpen={true}
|
||
>
|
||
<div className="space-y-3">
|
||
<Task
|
||
title="Assets-Generator"
|
||
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
|
||
startLabel="Start"
|
||
startingLabel="Starte…"
|
||
onTrigger={startAssetsTaskFromCurrentSwitches}
|
||
onCreated={(job) => {
|
||
if (!job?.id) return
|
||
upsertTask(setAssetTasks, mapAssetJobToTask(job))
|
||
}}
|
||
onError={(message) => {
|
||
setErr(message)
|
||
}}
|
||
/>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="space-y-2">
|
||
<LabeledSwitch
|
||
checked={!!value.generateAssetsMeta}
|
||
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsMeta: checked }))}
|
||
label="Meta"
|
||
description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.generateAssetsThumb}
|
||
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsThumb: checked }))}
|
||
label="Vorschaubild"
|
||
description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.generateAssetsTeaser}
|
||
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsTeaser: checked }))}
|
||
label="Teaser"
|
||
description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.generateAssetsSprites}
|
||
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsSprites: checked }))}
|
||
label="Sprites"
|
||
description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.generateAssetsAnalyze}
|
||
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))}
|
||
label="Analyse"
|
||
description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Task
|
||
title="Downloads prüfen"
|
||
description="Prüft fertige Videos anhand der preview-sprite.jpg auf schwarze Endzellen und schreibt das Ergebnis in die meta.json."
|
||
startLabel="Prüfen"
|
||
startingLabel="Starte…"
|
||
startUrl="/api/tasks/check-videos"
|
||
onCreated={(job) => {
|
||
if (!job?.id) return
|
||
upsertTask(setCheckVideoTasks, mapCheckVideosJobToTask(job))
|
||
}}
|
||
onError={(message) => {
|
||
setErr(message)
|
||
}}
|
||
/>
|
||
|
||
<Task
|
||
title="Aufräumen"
|
||
description='Löscht Dateien im doneDir kleiner als die Mindestgröße, optional Downloads bis zur gewählten Sterne-Bewertung, und entfernt verwaiste Assets.'
|
||
startLabel="Aufräumen"
|
||
startingLabel="Läuft…"
|
||
onTrigger={cleanupSmallDone}
|
||
disabled={saving}
|
||
onCreated={(job) => {
|
||
if (!job?.id) return
|
||
upsertTask(setCleanupTasks, mapCleanupJobToTask(job))
|
||
}}
|
||
onError={(message) => {
|
||
setErr(message)
|
||
}}
|
||
/>
|
||
</div>
|
||
</SettingsSection>
|
||
|
||
{/* Paths */}
|
||
<SettingsSection
|
||
id="paths"
|
||
title="Pfad-Einstellungen"
|
||
description="Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad."
|
||
icon="📁"
|
||
defaultOpen={false}
|
||
>
|
||
<div className="mb-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Pfad-Einstellungen</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
|
||
</div>
|
||
</div>
|
||
|
||
{/* Datenbank */}
|
||
<div className="mb-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
|
||
<div className="mb-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Postgres Verbindung. Passwort wird verschlüsselt gespeichert.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||
Database URL
|
||
</label>
|
||
|
||
<div className="sm:col-span-9 flex gap-2">
|
||
<input
|
||
value={String((value as any).databaseUrl ?? '')}
|
||
onChange={(e) => setValue((v) => ({ ...v, databaseUrl: e.target.value }))}
|
||
placeholder="postgres://user@host:5432/db?sslmode=disable"
|
||
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
|
||
<Button variant="secondary" onClick={() => setDbModalOpen(true)} disabled={saving}>
|
||
URL erstellen…
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-gray-200 pt-3 dark:border-white/10">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Backup / Restore
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her.
|
||
</div>
|
||
{databaseSettingsDirty ? (
|
||
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
|
||
Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern.
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
color="emerald"
|
||
isLoading={dbBackupBusy}
|
||
disabled={saving || dbMaintenanceBusy || !loadedDatabaseUrl || databaseSettingsDirty}
|
||
onClick={() => void downloadDatabaseBackup()}
|
||
>
|
||
Backup herunterladen
|
||
</Button>
|
||
|
||
<Button
|
||
variant="secondary"
|
||
color="amber"
|
||
isLoading={dbRestoreBusy}
|
||
disabled={saving || dbMaintenanceBusy || !loadedDatabaseUrl || databaseSettingsDirty}
|
||
onClick={chooseDatabaseRestoreFile}
|
||
>
|
||
Restore
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
ref={dbRestoreInputRef}
|
||
type="file"
|
||
accept=".dump,.backup,.bin,application/octet-stream"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const file = e.currentTarget.files?.[0] ?? null
|
||
e.currentTarget.value = ''
|
||
void restoreDatabaseBackup(file)
|
||
}}
|
||
/>
|
||
|
||
{dbMaintenanceErr ? (
|
||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||
{dbMaintenanceErr}
|
||
</div>
|
||
) : dbMaintenanceMsg ? (
|
||
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200">
|
||
{dbMaintenanceMsg}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<PostgresUrlModal
|
||
open={dbModalOpen}
|
||
onClose={() => setDbModalOpen(false)}
|
||
initialUrl={String((value as any).databaseUrl ?? '')}
|
||
initialHasPassword={Boolean((value as any).hasDbPassword)}
|
||
onApply={({ databaseUrl, dbPassword }) => {
|
||
setValue((v) => ({ ...v, databaseUrl }))
|
||
setPendingDbPassword(dbPassword || '')
|
||
}}
|
||
/>
|
||
|
||
<div className="space-y-3">
|
||
{/* Aufnahme-Ordner */}
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||
Aufnahme-Ordner
|
||
</label>
|
||
<div className="sm:col-span-9 flex gap-2">
|
||
<input
|
||
value={value.recordDir}
|
||
onChange={(e) => setValue((v) => ({ ...v, recordDir: e.target.value }))}
|
||
placeholder="records (oder absolut: C:\records / /mnt/data/records)"
|
||
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
<Button variant="secondary" onClick={() => browse('record')} disabled={saving || browsing !== null}>
|
||
Durchsuchen...
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Fertige Downloads */}
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||
Fertige Downloads nach
|
||
</label>
|
||
<div className="sm:col-span-9 flex gap-2">
|
||
<input
|
||
value={value.doneDir}
|
||
onChange={(e) => setValue((v) => ({ ...v, doneDir: e.target.value }))}
|
||
placeholder="records/done"
|
||
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
<Button variant="secondary" onClick={() => browse('done')} disabled={saving || browsing !== null}>
|
||
Durchsuchen...
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ffmpeg.exe */}
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
|
||
ffmpeg.exe
|
||
</label>
|
||
<div className="sm:col-span-9 flex gap-2">
|
||
<input
|
||
value={value.ffmpegPath ?? ''}
|
||
onChange={(e) => setValue((v) => ({ ...v, ffmpegPath: e.target.value }))}
|
||
placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)"
|
||
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
<Button variant="secondary" onClick={() => browse('ffmpeg')} disabled={saving || browsing !== null}>
|
||
Durchsuchen...
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SettingsSection>
|
||
|
||
{/* Automatisierung */}
|
||
<SettingsSection
|
||
id="automation"
|
||
title="Automatisierung & Anzeige"
|
||
description="Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen."
|
||
icon="🤖"
|
||
defaultOpen={false}
|
||
>
|
||
<div className="mb-3">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5 mb-2">
|
||
<LabeledSwitch
|
||
checked={!!value.enableConcurrentDownloadsLimit}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
enableConcurrentDownloadsLimit: checked,
|
||
maxConcurrentDownloads: v.maxConcurrentDownloads ?? 20,
|
||
}))
|
||
}
|
||
label="Gleichzeitige Downloads begrenzen"
|
||
description="Begrenzt die Anzahl parallel laufender Downloads. Weitere Starts werden vom Backend blockiert."
|
||
/>
|
||
|
||
<div
|
||
className={
|
||
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
|
||
(!value.enableConcurrentDownloadsLimit ? 'opacity-50 pointer-events-none' : '')
|
||
}
|
||
>
|
||
<div className="sm:col-span-4">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Max. parallele Downloads</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Mehr gleichzeitige Starts werden nicht zugelassen.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sm:col-span-8">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
step={1}
|
||
value={value.maxConcurrentDownloads ?? 20}
|
||
onChange={(e) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
maxConcurrentDownloads: Number(e.target.value || 1),
|
||
}))
|
||
}
|
||
className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<LabeledSwitch
|
||
checked={!!value.enrichPostworkEnabled}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
enrichPostworkEnabled: checked,
|
||
}))
|
||
}
|
||
label="EnrichQ-Postwork im Hintergrund"
|
||
description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.autoAddToDownloadList}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoAddToDownloadList: checked,
|
||
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
|
||
}))
|
||
}
|
||
label="Clipboard-Links in Start-URL übernehmen"
|
||
description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.autoStartAddedDownloads}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoStartAddedDownloads: checked,
|
||
autoAddToDownloadList: checked ? true : v.autoAddToDownloadList,
|
||
}))
|
||
}
|
||
label="Übernommene Links automatisch starten"
|
||
description="Startet automatisch, sobald ein erkannter Clipboard-Link übernommen wurde oder ein aktivierter Provider ein watched Model als öffentlich online erkennt. Aktiviert automatisch auch die Clipboard-Übernahme."
|
||
/>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<LabeledSwitch
|
||
checked={!!value.useProviderApis}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
useProviderApis: checked,
|
||
useChaturbateApi: checked ? v.useChaturbateApi : false,
|
||
useMyFreeCamsApi: checked ? v.useMyFreeCamsApi : false,
|
||
}))
|
||
}
|
||
label="Provider-APIs verwenden"
|
||
description="Master-Schalter für externe Provider-APIs. Wenn deaktiviert, werden keine automatischen API-Abfragen für Online-Status, Profilbilder oder Model-Daten ausgeführt."
|
||
/>
|
||
|
||
<div
|
||
className={
|
||
'mt-3 space-y-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
|
||
(!value.useProviderApis ? 'opacity-50 pointer-events-none' : '')
|
||
}
|
||
>
|
||
<LabeledSwitch
|
||
checked={!!value.useChaturbateApi}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
useProviderApis: checked ? true : v.useProviderApis,
|
||
useChaturbateApi: checked,
|
||
}))
|
||
}
|
||
label="Chaturbate API"
|
||
description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über die Chaturbate-API. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.useMyFreeCamsApi}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
useProviderApis: checked ? true : v.useProviderApis,
|
||
useMyFreeCamsApi: checked,
|
||
}))
|
||
}
|
||
label="MyFreeCams API"
|
||
description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über usernameLookup. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet. Bei 'servers busy' pausiert die App MyFreeCams-Anfragen automatisch."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ✅ NEU: Auto-Delete kleine Downloads */}
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<LabeledSwitch
|
||
checked={!!value.autoDeleteSmallDownloads}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoDeleteSmallDownloads: checked,
|
||
autoDeleteSmallDownloadsBelowMB:
|
||
v.autoDeleteSmallDownloadsBelowMB ?? 50,
|
||
}))
|
||
}
|
||
label="Kleine Downloads automatisch löschen"
|
||
description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist."
|
||
/>
|
||
|
||
<div
|
||
className={
|
||
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
|
||
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
|
||
}
|
||
>
|
||
<div className="sm:col-span-4">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Mindestgröße</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">Alles darunter wird gelöscht.</div>
|
||
</div>
|
||
|
||
<div className="sm:col-span-8">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={1}
|
||
value={value.autoDeleteSmallDownloadsBelowMB ?? 50}
|
||
onChange={(e) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoDeleteSmallDownloadsBelowMB: Number(e.target.value || 0),
|
||
}))
|
||
}
|
||
className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
|
||
/>
|
||
|
||
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={
|
||
'mt-3 border-t border-gray-200 pt-3 dark:border-white/10 ' +
|
||
(!value.autoDeleteSmallDownloads ? 'opacity-50 pointer-events-none' : '')
|
||
}
|
||
>
|
||
<LabeledSwitch
|
||
checked={!!value.autoDeleteSmallDownloadsKeepFavorites}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoDeleteSmallDownloadsKeepFavorites: checked,
|
||
}))
|
||
}
|
||
label="Favoriten nicht automatisch löschen"
|
||
description="Schützt Downloads von favorisierten Models vor dem automatischen Löschen, auch wenn die Datei kleiner als die Mindestgröße ist."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<LabeledSwitch
|
||
checked={!!value.autoDeleteLowRatedDownloads}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoDeleteLowRatedDownloads: checked,
|
||
autoDeleteLowRatedDownloadsMaxStars:
|
||
v.autoDeleteLowRatedDownloadsMaxStars ?? 3,
|
||
}))
|
||
}
|
||
label="Downloads nach Analyse nach Rating löschen"
|
||
description="Löscht fertige Downloads automatisch nach der Analyse, wenn das Rating höchstens der gewählten Sternezahl entspricht."
|
||
/>
|
||
|
||
<div
|
||
className={
|
||
'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center ' +
|
||
(!value.autoDeleteLowRatedDownloads ? 'opacity-50 pointer-events-none' : '')
|
||
}
|
||
>
|
||
<div className="sm:col-span-5">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||
Sterne-Grenze
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Bis einschließlich dieser Bewertung wird gelöscht.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sm:col-span-7">
|
||
<RatingThresholdStars
|
||
value={value.autoDeleteLowRatedDownloadsMaxStars}
|
||
disabled={!value.autoDeleteLowRatedDownloads}
|
||
onChange={(stars) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
autoDeleteLowRatedDownloadsMaxStars: stars,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<LabeledSwitch
|
||
checked={!!value.trainingRecognitionEnabled}
|
||
onChange={(checked) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
trainingRecognitionEnabled: checked,
|
||
}))
|
||
}
|
||
label="Training-Erkennung aktivieren"
|
||
description="Wenn aktiv, werden neue Trainingsbilder automatisch mit YOLO und Scene-KNN analysiert. Wenn deaktiviert, kannst du weiterhin manuell korrigieren und Boxen zeichnen."
|
||
/>
|
||
|
||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||
<div className="sm:col-span-9">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||
Object Detection Epochs
|
||
</div>
|
||
<div className="max-w-4xl text-xs text-gray-600 dark:text-gray-300">
|
||
Anzahl der Trainingsdurchläufe für die Box-Erkennung. Mehr Epochs können die Erkennung verbessern, dauern aber länger. 60 ist ein guter Standard.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sm:col-span-3">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={300}
|
||
step={1}
|
||
value={value.trainingDetectorEpochs ?? 60}
|
||
onChange={(e) =>
|
||
setValue((v) => ({
|
||
...v,
|
||
trainingDetectorEpochs: Number(e.target.value || 60),
|
||
}))
|
||
}
|
||
className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
|
||
/>
|
||
|
||
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">
|
||
Epochs
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.blurPreviews}
|
||
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
|
||
label="Vorschaubilder blurren"
|
||
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)."
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||
<div className="sm:col-span-8">
|
||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||
Teaser abspielen
|
||
</div>
|
||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||
Standbild spart Leistung. „Bei Hover (Standard)“: Desktop spielt bei Hover ab, Mobile im Viewport. „Alle“ kann viel CPU ziehen.
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sm:col-span-4 sm:flex sm:justify-end">
|
||
<label className="sr-only" htmlFor="teaserPlayback">
|
||
Teaser abspielen
|
||
</label>
|
||
<select
|
||
id="teaserPlayback"
|
||
value={value.teaserPlayback ?? 'hover'}
|
||
onChange={(e) =>
|
||
setValue((v) => ({ ...v, teaserPlayback: e.target.value as any }))
|
||
}
|
||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
|
||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
||
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
|
||
sm:max-w-56"
|
||
>
|
||
<option value="still">Standbild</option>
|
||
<option value="hover">Bei Hover (Standard)</option>
|
||
<option value="all">Alle</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.teaserAudio}
|
||
onChange={(checked) => setValue((v) => ({ ...v, teaserAudio: checked }))}
|
||
label="Teaser mit Ton"
|
||
description="Wenn aktiv, werden Vorschau/Teaser nicht stumm geschaltet."
|
||
/>
|
||
|
||
<LabeledSwitch
|
||
checked={!!value.enableNotifications}
|
||
onChange={(checked) => setValue((v) => ({ ...v, enableNotifications: checked }))}
|
||
label="Benachrichtigungen"
|
||
description="Wenn aktiv, zeigt das Frontend Toasts (z.B. wenn watched Models online/live gehen oder wenn ein queued Model wieder public wird)."
|
||
/>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Speicherplatz-Notbremse</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Aktiviert automatisch Stop + Autostart-Block bei wenig freiem Speicher (Resume bei +3 GB).
|
||
</div>
|
||
</div>
|
||
|
||
<span
|
||
className={
|
||
'inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ' +
|
||
(diskStatus?.emergency
|
||
? 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200'
|
||
: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-200')
|
||
}
|
||
title={diskStatus?.emergency ? 'Notfallbremse greift gerade' : 'OK'}
|
||
>
|
||
{diskStatus?.emergency ? 'AKTIV' : 'OK'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-3 text-sm text-gray-900 dark:text-gray-200">
|
||
<div>
|
||
<span className="font-medium">Schwelle:</span>{' '}
|
||
Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB
|
||
{' · '}Resume ab{' '}
|
||
<span className="tabular-nums">
|
||
{uiResumeGB}
|
||
</span>{' '}
|
||
GB
|
||
</div>
|
||
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
{diskStatus
|
||
? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}`
|
||
: 'Status wird geladen…'}
|
||
</div>
|
||
|
||
{diskStatus?.emergency && (
|
||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||
Notfallbremse greift: laufende Downloads werden gestoppt und Autostart bleibt gesperrt, bis wieder genug frei ist.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setAppLogOpen((v) => !v)}
|
||
className="flex w-full items-start justify-between gap-3 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Log
|
||
</div>
|
||
|
||
{appLog?.sizeBytes ? (
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
|
||
{Math.ceil(appLog.sizeBytes / 1024)} KB
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-300">
|
||
Zeigt die aktuelle recorder.log aus dem App-Verzeichnis.
|
||
</div>
|
||
</div>
|
||
|
||
<span
|
||
className={[
|
||
'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
|
||
appLogOpen ? 'rotate-180' : '',
|
||
].join(' ')}
|
||
aria-hidden="true"
|
||
>
|
||
▾
|
||
</span>
|
||
</button>
|
||
|
||
{appLogOpen ? (
|
||
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-white/10">
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||
<div className="min-w-0 text-xs text-gray-600 dark:text-gray-300">
|
||
{appLog?.path ? (
|
||
<span className="break-all">
|
||
Logdatei: {appLog.path}
|
||
</span>
|
||
) : appLogLoading ? (
|
||
'Logdatei wird geladen…'
|
||
) : (
|
||
'Logdatei noch nicht geladen.'
|
||
)}
|
||
</div>
|
||
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={appLogLoading}
|
||
onClick={() => void loadAppLog()}
|
||
>
|
||
{appLogLoading ? 'Lade…' : 'Aktualisieren'}
|
||
</Button>
|
||
</div>
|
||
|
||
{appLog?.error ? (
|
||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||
{appLog.error}
|
||
</div>
|
||
) : null}
|
||
|
||
<pre
|
||
ref={appLogPreRef}
|
||
className="max-h-96 overflow-auto whitespace-pre-wrap rounded-xl bg-gray-950 p-3 font-mono text-[11px] leading-relaxed text-gray-100 ring-1 ring-black/10 dark:ring-white/10"
|
||
>
|
||
{appLogLoading
|
||
? 'Log wird geladen…'
|
||
: appLog?.log?.trim()
|
||
? appLog.log
|
||
: 'Noch kein Log vorhanden.'}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</SettingsSection>
|
||
|
||
{/* Sicherheit */}
|
||
<SettingsSection
|
||
id="security"
|
||
title="Konto-Sicherheit"
|
||
description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten."
|
||
icon="🛡️"
|
||
defaultOpen={false}
|
||
actions={
|
||
<>
|
||
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
|
||
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
|
||
</>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Passwort ändern
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||
<input
|
||
type="password"
|
||
value={currentPassword}
|
||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||
placeholder="Aktuelles Passwort"
|
||
autoComplete="current-password"
|
||
disabled={securityBusy}
|
||
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
|
||
<input
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
placeholder="Neues Passwort"
|
||
autoComplete="new-password"
|
||
disabled={securityBusy}
|
||
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
|
||
<input
|
||
type="password"
|
||
value={newPasswordRepeat}
|
||
onChange={(e) => setNewPasswordRepeat(e.target.value)}
|
||
placeholder="Neues Passwort wiederholen"
|
||
autoComplete="new-password"
|
||
disabled={securityBusy}
|
||
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-3 flex justify-end">
|
||
<Button
|
||
variant="primary"
|
||
disabled={securityBusy}
|
||
onClick={() => void changePassword()}
|
||
>
|
||
Passwort speichern
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Zwei-Faktor-Authentifizierung
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
|
||
</div>
|
||
</div>
|
||
|
||
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2">
|
||
{authStatus?.totpDevice ? (
|
||
<SecurityDeviceRow
|
||
icon="🔐"
|
||
title={authStatus.totpDevice.label || 'Authenticator-App'}
|
||
subtitle={
|
||
authStatus?.totpEnabled
|
||
? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
|
||
: '2FA ist eingerichtet, aber aktuell nicht aktiv.'
|
||
}
|
||
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
|
||
badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
|
||
/>
|
||
) : (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
|
||
<>
|
||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<input
|
||
type="password"
|
||
value={disable2FAPassword}
|
||
onChange={(e) => {
|
||
setDisable2FAPassword(e.target.value)
|
||
setDisable2FAFieldError((cur) => ({ ...cur, password: undefined }))
|
||
}}
|
||
placeholder="Aktuelles Passwort"
|
||
autoComplete="current-password"
|
||
disabled={securityBusy}
|
||
className={[
|
||
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
|
||
disable2FAFieldError?.password
|
||
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
|
||
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
|
||
].join(' ')}
|
||
/>
|
||
|
||
<input
|
||
value={disable2FACode}
|
||
onChange={(e) => {
|
||
setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||
setDisable2FAFieldError((cur) => ({ ...cur, code: undefined }))
|
||
}}
|
||
placeholder="2FA-Code"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
disabled={securityBusy}
|
||
className={[
|
||
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
|
||
disable2FAFieldError?.code
|
||
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
|
||
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
|
||
].join(' ')}
|
||
/>
|
||
</div>
|
||
|
||
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
|
||
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||
{disable2FAFieldError.password || disable2FAFieldError.code}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
Zum Deaktivieren brauchst du dein aktuelles Passwort
|
||
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-3 flex justify-end">
|
||
<Button
|
||
variant="primary"
|
||
color="red"
|
||
disabled={securityBusy}
|
||
onClick={() => void disable2FA()}
|
||
>
|
||
2FA deaktivieren
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
Passkeys
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
|
||
</div>
|
||
</div>
|
||
|
||
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-2">
|
||
{(authStatus?.passkeys ?? []).length > 0 ? (
|
||
(authStatus?.passkeys ?? []).map((pk, index) => (
|
||
<SecurityDeviceRow
|
||
key={pk.id || index}
|
||
icon="🔑"
|
||
title={pk.label || `Passkey ${index + 1}`}
|
||
subtitle={
|
||
pk.backupEligible
|
||
? pk.backupState
|
||
? 'Synchronisierter Passkey'
|
||
: 'Passkey kann synchronisiert werden'
|
||
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
|
||
}
|
||
meta={[
|
||
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
|
||
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
|
||
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
|
||
].filter(Boolean).join(' · ')}
|
||
badge="Passkey"
|
||
actions={
|
||
<Button
|
||
variant="secondary"
|
||
color="red"
|
||
size="sm"
|
||
disabled={securityBusy}
|
||
onClick={() => void deletePasskey(pk)}
|
||
>
|
||
Löschen
|
||
</Button>
|
||
}
|
||
/>
|
||
))
|
||
) : (
|
||
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
|
||
Noch kein Passkey gespeichert.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!passkeySupported ? (
|
||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 space-y-2">
|
||
{passkeyFeedback ? (
|
||
<div
|
||
className={[
|
||
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
|
||
passkeyFeedback.kind === 'success'
|
||
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
|
||
: passkeyFeedback.kind === 'error'
|
||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
|
||
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
|
||
].join(' ')}
|
||
>
|
||
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
|
||
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
|
||
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
|
||
{passkeyFeedback.text}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||
<Button
|
||
variant="secondary"
|
||
disabled={securityBusy}
|
||
onClick={() => void registerSettingsPasskey()}
|
||
>
|
||
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SettingsSection>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|