This commit is contained in:
Linrador 2026-06-24 09:18:00 +02:00
parent 3e32f9480a
commit a12d9dd1a9
7 changed files with 126 additions and 117 deletions

View File

@ -1,7 +1,7 @@
# Build and Debian package metadata. # Build and Debian package metadata.
PACKAGE_NAME="nsfwapp" PACKAGE_NAME="nsfwapp"
APP_TITLE="NSFW App" APP_TITLE="NSFW App"
VERSION="1.2.1" VERSION="1.3.0"
ARCH="amd64" ARCH="amd64"
SECTION="video" SECTION="video"
PRIORITY="optional" PRIORITY="optional"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -25,10 +25,12 @@ const (
func normalizePostgresMode(raw string) string { func normalizePostgresMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) { switch strings.ToLower(strings.TrimSpace(raw)) {
case postgresModeURL:
return postgresModeURL
case postgresModeLocal: case postgresModeLocal:
return postgresModeLocal return postgresModeLocal
default: default:
return postgresModeURL return postgresModeLocal
} }
} }
@ -76,12 +78,16 @@ func normalizePostgresSettings(s *RecorderSettings) {
return return
} }
s.DatabaseType = normalizeDatabaseType(s.DatabaseType) s.DatabaseType = normalizeDatabaseType(s.DatabaseType)
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
if strings.TrimSpace(s.PostgresMode) == "" && s.DatabaseURL != "" {
s.PostgresMode = postgresModeURL
} else {
s.PostgresMode = normalizePostgresMode(s.PostgresMode) s.PostgresMode = normalizePostgresMode(s.PostgresMode)
}
s.PostgresDataDir = normalizePostgresDataDir(s.PostgresDataDir) s.PostgresDataDir = normalizePostgresDataDir(s.PostgresDataDir)
s.PostgresPort = normalizePostgresPort(s.PostgresPort) s.PostgresPort = normalizePostgresPort(s.PostgresPort)
s.PostgresDatabase = normalizePostgresName(s.PostgresDatabase, defaultPostgresDatabase) s.PostgresDatabase = normalizePostgresName(s.PostgresDatabase, defaultPostgresDatabase)
s.PostgresUser = normalizePostgresName(s.PostgresUser, defaultPostgresUser) s.PostgresUser = normalizePostgresName(s.PostgresUser, defaultPostgresUser)
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
} }
func postgresSettingsKey(s RecorderSettings) string { func postgresSettingsKey(s RecorderSettings) string {

View File

@ -72,7 +72,7 @@ var (
settingsMu sync.Mutex settingsMu sync.Mutex
settings = RecorderSettings{ settings = RecorderSettings{
DatabaseType: databaseTypePostgres, DatabaseType: databaseTypePostgres,
PostgresMode: postgresModeURL, PostgresMode: postgresModeLocal,
PostgresDataDir: defaultPostgresDataDir, PostgresDataDir: defaultPostgresDataDir,
PostgresPort: defaultPostgresPort, PostgresPort: defaultPostgresPort,
PostgresDatabase: defaultPostgresDatabase, PostgresDatabase: defaultPostgresDatabase,

View File

@ -286,7 +286,7 @@ function SettingsSection(props: {
const DEFAULTS: RecorderSettings = { const DEFAULTS: RecorderSettings = {
databaseType: 'postgres', databaseType: 'postgres',
postgresMode: 'url', postgresMode: 'local',
postgresDataDir: 'postgres-data', postgresDataDir: 'postgres-data',
postgresPort: 55432, postgresPort: 55432,
postgresDatabase: 'nsfwapp', postgresDatabase: 'nsfwapp',
@ -355,7 +355,7 @@ function shortTaskFilename(name?: string, max = 52) {
const s = String(name ?? '').trim() const s = String(name ?? '').trim()
if (!s) return '' if (!s) return ''
if (s.length <= max) return s if (s.length <= max) return s
return '…' + s.slice(-(max - 1)) return '' + s.slice(-(max - 1))
} }
function regenerateTaskId(file: string) { function regenerateTaskId(file: string) {
@ -390,7 +390,7 @@ function assetsTaskText(file?: string, label?: string, phase?: string) {
const fn = shortTaskFilename(file, 38) const fn = shortTaskFilename(file, 38)
const phaseText = assetsPhaseLabel(label, phase) const phaseText = assetsPhaseLabel(label, phase)
if (fn && phaseText) return `${fn} · ${phaseText}` if (fn && phaseText) return `${fn} · ${phaseText}`
if (fn) return fn if (fn) return fn
return phaseText return phaseText
} }
@ -478,12 +478,12 @@ function mapCheckVideosJobToTask(job: any): TaskItem {
return { return {
id: checkVideosTaskDomId(String(job?.id || '')), id: checkVideosTaskDomId(String(job?.id || '')),
status, status,
title: 'Downloads prüfen', title: 'Downloads prüfen',
text: text:
status === 'running' status === 'running'
? (String(job?.currentFile || '').trim() ? (String(job?.currentFile || '').trim()
? shortTaskFilename(String(job?.currentFile || '').trim(), 52) ? shortTaskFilename(String(job?.currentFile || '').trim(), 52)
: 'Prüfe Videos…') : 'Prüfe Videos…')
: String(job?.text || ''), : String(job?.text || ''),
done: Number(job?.done ?? 0), done: Number(job?.done ?? 0),
total: Number(job?.total ?? 0), total: Number(job?.total ?? 0),
@ -524,9 +524,9 @@ function cleanupTaskTitle(rawId: string) {
const id = String(rawId || '').trim().toLowerCase() const id = String(rawId || '').trim().toLowerCase()
if (id.startsWith('cleanup-leftovers-startup')) return 'Startup-Cleanup' if (id.startsWith('cleanup-leftovers-startup')) return 'Startup-Cleanup'
if (id.startsWith('cleanup-leftovers-hourly')) return 'Stündlicher Cleanup' if (id.startsWith('cleanup-leftovers-hourly')) return 'Stündlicher Cleanup'
if (id.startsWith('cleanup-leftovers')) return 'Record-Reste prüfen' if (id.startsWith('cleanup-leftovers')) return 'Record-Reste prüfen'
return 'Aufräumen' return 'Aufräumen'
} }
function databaseBackupFilename(contentDisposition: string | null) { function databaseBackupFilename(contentDisposition: string | null) {
@ -550,8 +550,11 @@ function normalizeDatabaseType(_value: unknown): 'postgres' {
return 'postgres' return 'postgres'
} }
function normalizePostgresMode(value: unknown): 'url' | 'local' { function normalizePostgresMode(value: unknown, databaseUrl?: unknown): 'url' | 'local' {
return String(value ?? '').trim().toLowerCase() === 'local' ? 'local' : 'url' const mode = String(value ?? '').trim().toLowerCase()
if (mode === 'url') return 'url'
if (mode === 'local') return 'local'
return String(databaseUrl ?? '').trim() ? 'url' : 'local'
} }
function normalizePostgresDataDir(value: unknown) { function normalizePostgresDataDir(value: unknown) {
@ -569,7 +572,7 @@ function normalizePostgresName(value: unknown, fallback: string) {
} }
function postgresSourceKey(v: any) { function postgresSourceKey(v: any) {
const mode = normalizePostgresMode(v?.postgresMode) const mode = normalizePostgresMode(v?.postgresMode, v?.databaseUrl)
if (mode === 'local') { if (mode === 'local') {
return [ return [
mode, mode,
@ -693,7 +696,7 @@ function RatingThresholdStars({
role="radio" role="radio"
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
title={`Bis einschließlich ${stars} Stern${stars === 1 ? '' : 'e'} löschen`} title={`Bis einschließlich ${stars} Stern${stars === 1 ? '' : 'e'} löschen`}
onClick={() => onChange(stars)} onClick={() => onChange(stars)}
className={[ className={[
'rounded-md p-0.5 transition', 'rounded-md p-0.5 transition',
@ -710,7 +713,7 @@ function RatingThresholdStars({
</div> </div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
LÃscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}. Löscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}.
</div> </div>
</div> </div>
) )
@ -733,7 +736,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [appLogOpen, setAppLogOpen] = useState(false) const [appLogOpen, setAppLogOpen] = useState(false)
const [dbModalOpen, setDbModalOpen] = useState(false) const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres') const [loadedDatabaseType, setLoadedDatabaseType] = useState<'postgres'>('postgres')
const [loadedPostgresMode, setLoadedPostgresMode] = useState<'url' | 'local'>('url') const [loadedPostgresMode, setLoadedPostgresMode] = useState<'url' | 'local'>('local')
const [loadedPostgresDataDir, setLoadedPostgresDataDir] = useState('postgres-data') const [loadedPostgresDataDir, setLoadedPostgresDataDir] = useState('postgres-data')
const [loadedPostgresPort, setLoadedPostgresPort] = useState(55432) const [loadedPostgresPort, setLoadedPostgresPort] = useState(55432)
const [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp') const [loadedPostgresDatabase, setLoadedPostgresDatabase] = useState('nsfwapp')
@ -748,7 +751,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const now = Date.now() const now = Date.now()
const saveSucceeded = saveSuccessUntilMs > now const saveSucceeded = saveSuccessUntilMs > now
const saveFailed = Boolean(err) && !saving && !saveSucceeded const saveFailed = Boolean(err) && !saving && !saveSucceeded
const currentPostgresMode = normalizePostgresMode((value as any).postgresMode) const currentPostgresMode = normalizePostgresMode((value as any).postgresMode, (value as any).databaseUrl)
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim() const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
const loadedPostgresSourceKey = postgresSourceKey({ const loadedPostgresSourceKey = postgresSourceKey({
databaseType: loadedDatabaseType, databaseType: loadedDatabaseType,
@ -809,9 +812,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const saveButton = (() => { const saveButton = (() => {
if (saveUiState === 'saving') { if (saveUiState === 'saving') {
return { return {
text: 'Speichern…', text: 'Speichern',
color: 'blue' as const, color: 'blue' as const,
icon: null, // Spinner kommt über isLoading icon: null, // Spinner kommt über isLoading
isLoading: true, isLoading: true,
} }
} }
@ -910,7 +913,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
.then((data: RecorderSettings) => { .then((data: RecorderSettings) => {
if (!alive) return if (!alive) return
const databaseType = normalizeDatabaseType((data as any).databaseType) const databaseType = normalizeDatabaseType((data as any).databaseType)
const postgresMode = normalizePostgresMode((data as any).postgresMode) const postgresMode = normalizePostgresMode((data as any).postgresMode, (data as any).databaseUrl)
const postgresDataDir = normalizePostgresDataDir((data as any).postgresDataDir) const postgresDataDir = normalizePostgresDataDir((data as any).postgresDataDir)
const postgresPort = normalizePostgresPort((data as any).postgresPort) const postgresPort = normalizePostgresPort((data as any).postgresPort)
const postgresDatabase = normalizePostgresName((data as any).postgresDatabase, 'nsfwapp') const postgresDatabase = normalizePostgresName((data as any).postgresDatabase, 'nsfwapp')
@ -929,7 +932,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''), ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen // falls backend die Felder noch nicht hat -> defaults nutzen
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList, autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
autoStartAddedDownloads: autoStartAddedDownloads:
(data as any).autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads, (data as any).autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
@ -1088,7 +1091,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const text = const text =
status === 'running' || status === 'queued' status === 'running' || status === 'queued'
? phaseLabel ? phaseLabel
? `${shortTaskFilename(file, 38)} · ${phaseLabel}` ? `${shortTaskFilename(file, 38)} · ${phaseLabel}`
: shortTaskFilename(file, 52) : shortTaskFilename(file, 52)
: shortTaskFilename(file, 52) : shortTaskFilename(file, 52)
@ -1389,7 +1392,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const ok = window.confirm( const ok = window.confirm(
`Datenbank-Backup "${file.name}" wiederherstellen?\n\n` + `Datenbank-Backup "${file.name}" wiederherstellen?\n\n` +
'Das überschreibt die aktuelle Datenbank. Dieser Schritt kann nicht automatisch rueckgaengig gemacht werden.' 'Das überschreibt die aktuelle Datenbank. Dieser Schritt kann nicht automatisch rückgängig gemacht werden.'
) )
if (!ok) return if (!ok) return
@ -1463,7 +1466,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
: `Datenbank "${databaseUrl}"` : `Datenbank "${databaseUrl}"`
const ok = window.confirm( const ok = window.confirm(
`Aktive PostgreSQL-Datenbank nach ${targetLabel} migrieren?\n\n` + `Aktive PostgreSQL-Datenbank nach ${targetLabel} migrieren?\n\n` +
'Die Ziel-Datenbank wird mit den aktuellen Model-Daten ueberschrieben. Danach nutzt die App das Ziel.' 'Die Ziel-Datenbank wird mit den aktuellen Model-Daten überschrieben. Danach nutzt die App das Ziel.'
) )
if (!ok) return if (!ok) return
@ -1533,7 +1536,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const doneDir = value.doneDir.trim() const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim() const ffmpegPath = (value.ffmpegPath ?? '').trim()
const databaseType = normalizeDatabaseType((value as any).databaseType) const databaseType = normalizeDatabaseType((value as any).databaseType)
const postgresMode = normalizePostgresMode((value as any).postgresMode) const postgresMode = normalizePostgresMode((value as any).postgresMode, (value as any).databaseUrl)
const postgresDataDir = normalizePostgresDataDir((value as any).postgresDataDir) const postgresDataDir = normalizePostgresDataDir((value as any).postgresDataDir)
const postgresPort = normalizePostgresPort((value as any).postgresPort) const postgresPort = normalizePostgresPort((value as any).postgresPort)
const postgresDatabase = normalizePostgresName((value as any).postgresDatabase, 'nsfwapp') const postgresDatabase = normalizePostgresName((value as any).postgresDatabase, 'nsfwapp')
@ -1554,7 +1557,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
return return
} }
// ✅ Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist // Switch-Logik: Autostart nur sinnvoll, wenn Auto-Add aktiv ist
const autoAddToDownloadList = !!value.autoAddToDownloadList const autoAddToDownloadList = !!value.autoAddToDownloadList
const autoStartAddedDownloads = !!value.autoStartAddedDownloads const autoStartAddedDownloads = !!value.autoStartAddedDownloads
const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit const enableConcurrentDownloadsLimit = !!(value as any).enableConcurrentDownloadsLimit
@ -1646,7 +1649,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const t = await res.text().catch(() => '') const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`) throw new Error(t || `HTTP ${res.status}`)
} }
// Button-Label: "Gespeichert" für 2.5s // Button-Label: "Gespeichert" für 2.5s
const until = Date.now() + 2500 const until = Date.now() + 2500
setSaveSuccessUntilMs(until) setSaveSuccessUntilMs(until)
@ -1736,24 +1739,24 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
return null return null
} }
if ((!mb || mb <= 0) && !deleteLowRated) { if ((!mb || mb <= 0) && !deleteLowRated) {
setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert – es würde nichts gelöscht.') setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert es würde nichts gelöscht.')
return null return null
} }
const ratingLine = deleteLowRated const ratingLine = deleteLowRated
? `• Rating ≤ ${maxStars} Stern${maxStars === 1 ? '' : 'e'}\n` ? `• Rating ≤ ${maxStars} Stern${maxStars === 1 ? '' : 'e'}\n`
: '' : ''
const sizeLine = mb > 0 const sizeLine = mb > 0
? `• Dateien < ${mb} MB in "${doneDir}"\n` ? ` Dateien < ${mb} MB in "${doneDir}"\n`
: '' : ''
const ok = window.confirm( const ok = window.confirm(
`Aufräumen:\n` + `Aufräumen:\n` +
sizeLine + sizeLine +
ratingLine + ratingLine +
`• Entfernt defekte Video-Dateien\n` + ` Entfernt defekte Video-Dateien\n` +
`• Löscht Record-Reste und verwaiste Assets\n\n` + `• Löscht Record-Reste und verwaiste Assets\n\n` +
`Fortfahren?` `Fortfahren?`
) )
if (!ok) return null if (!ok) return null
@ -1883,7 +1886,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
if (newPassword !== newPasswordRepeat) { if (newPassword !== newPasswordRepeat) {
setErr('Die neuen Passwörter stimmen nicht überein.') setErr('Die neuen Passwörter stimmen nicht überein.')
return return
} }
@ -1908,7 +1911,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setCurrentPassword('') setCurrentPassword('')
setNewPassword('') setNewPassword('')
setNewPasswordRepeat('') setNewPasswordRepeat('')
setMsg('Passwort wurde geändert.') setMsg('Passwort wurde geändert.')
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
} finally { } finally {
@ -1942,7 +1945,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
return return
} }
const ok = window.confirm('2FA wirklich deaktivieren? Dein Login ist danach weniger geschützt.') const ok = window.confirm('2FA wirklich deaktivieren? Dein Login ist danach weniger geschützt.')
if (!ok) return if (!ok) return
setSecurityBusy(true) setSecurityBusy(true)
@ -1978,7 +1981,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
function passkeyErrorMessage(e: any) { function passkeyErrorMessage(e: any) {
const raw = String(e?.message ?? e ?? '').trim() const raw = String(e?.message ?? e ?? '').trim()
if (!raw) return 'Passkey konnte nicht hinzugefügt werden.' if (!raw) return 'Passkey konnte nicht hinzugefügt werden.'
if (raw.includes('passkey registration verify failed')) { if (raw.includes('passkey registration verify failed')) {
return ( return (
@ -2021,14 +2024,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setMsg(null) setMsg(null)
setPasskeyFeedback({ setPasskeyFeedback({
kind: 'loading', kind: 'loading',
text: 'Starte Passkey-Einrichtung…', text: 'Starte Passkey-Einrichtung',
}) })
setSecurityBusy(true) setSecurityBusy(true)
try { try {
setPasskeyFeedback({ setPasskeyFeedback({
kind: 'loading', kind: 'loading',
text: 'Hole WebAuthn-Challenge vom Backend…', text: 'Hole WebAuthn-Challenge vom Backend',
}) })
const optionsRes = await fetch('/api/auth/passkey/register/options', { const optionsRes = await fetch('/api/auth/passkey/register/options', {
@ -2045,7 +2048,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setPasskeyFeedback({ setPasskeyFeedback({
kind: 'loading', kind: 'loading',
text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.', text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.',
}) })
const credential = await startRegistration({ const credential = await startRegistration({
@ -2054,7 +2057,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setPasskeyFeedback({ setPasskeyFeedback({
kind: 'loading', kind: 'loading',
text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend…', text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend',
}) })
const verifyRes = await fetch('/api/auth/passkey/register/verify', { const verifyRes = await fetch('/api/auth/passkey/register/verify', {
@ -2069,10 +2072,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
throw new Error(text || `HTTP ${verifyRes.status}`) throw new Error(text || `HTTP ${verifyRes.status}`)
} }
setMsg('Passkey wurde hinzugefügt.') setMsg('Passkey wurde hinzugefügt.')
setPasskeyFeedback({ setPasskeyFeedback({
kind: 'success', kind: 'success',
text: 'Passkey wurde erfolgreich hinzugefügt.', text: 'Passkey wurde erfolgreich hinzugefügt.',
}) })
await loadAuthStatus() await loadAuthStatus()
} catch (e: any) { } catch (e: any) {
@ -2094,14 +2097,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const credentialId = String(passkey?.id ?? '').trim() const credentialId = String(passkey?.id ?? '').trim()
if (!credentialId) { if (!credentialId) {
setErr('Passkey kann nicht gelöscht werden: Credential-ID fehlt.') setErr('Passkey kann nicht gelöscht werden: Credential-ID fehlt.')
return return
} }
const label = String(passkey?.label || 'diesen Passkey').trim() const label = String(passkey?.label || 'diesen Passkey').trim()
const ok = window.confirm( const ok = window.confirm(
`"${label}" wirklich löschen? Anmeldung mit diesem Passkey ist danach nicht mehr möglich.` `"${label}" wirklich löschen? Anmeldung mit diesem Passkey ist danach nicht mehr möglich.`
) )
if (!ok) return if (!ok) return
@ -2122,7 +2125,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
throw new Error(text || `HTTP ${res.status}`) throw new Error(text || `HTTP ${res.status}`)
} }
setMsg('Passkey wurde gelöscht.') setMsg('Passkey wurde gelöscht.')
await loadAuthStatus() await loadAuthStatus()
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
@ -2152,9 +2155,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
].join(' ')} ].join(' ')}
title={ title={
aiServerStatus === null aiServerStatus === null
? 'AI Server Status wird geladen…' ? 'AI Server Status wird geladen'
: aiServerStatus.running : aiServerStatus.running
? `AI Server läuft${aiServerStatus.url ? `: ${aiServerStatus.url}` : ''}` ? `AI Server läuft${aiServerStatus.url ? `: ${aiServerStatus.url}` : ''}`
: `AI Server offline${aiServerStatus.error ? `: ${aiServerStatus.error}` : ''}` : `AI Server offline${aiServerStatus.error ? `: ${aiServerStatus.error}` : ''}`
} }
> >
@ -2171,7 +2174,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/> />
{aiServerStatus === null {aiServerStatus === null
? 'AI prüft…' ? 'AI prüft…'
: aiServerStatus.running : aiServerStatus.running
? 'AI online' ? 'AI online'
: 'AI offline'} : 'AI offline'}
@ -2252,15 +2255,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
id="tasks" id="tasks"
title="Aufgaben" title="Aufgaben"
description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung." description="Hintergrundaufgaben wie z.B. Asset/Preview-Generierung."
icon="⚙️" icon="⚙️"
defaultOpen={true} defaultOpen={true}
> >
<div className="space-y-3"> <div className="space-y-3">
<Task <Task
title="Assets-Generator" title="Assets-Generator"
description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen." description="Erzeugt nur fehlende oder unvollständige Assets der aktivierten Phasen. Nicht ausgewählte Phasen werden komplett übersprungen."
startLabel="Start" startLabel="Start"
startingLabel="Starte…" startingLabel="Starte"
onTrigger={startAssetsTaskFromCurrentSwitches} onTrigger={startAssetsTaskFromCurrentSwitches}
onCreated={(job) => { onCreated={(job) => {
if (!job?.id) return if (!job?.id) return
@ -2277,44 +2280,44 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
checked={!!value.generateAssetsMeta} checked={!!value.generateAssetsMeta}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsMeta: checked }))} onChange={(checked) => setValue((v) => ({ ...v, generateAssetsMeta: checked }))}
label="Meta" label="Meta"
description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen." description="Erzeugt oder ergänzt die meta.json mit Dauer, Auflösung, FPS und weiteren Videoinformationen."
/> />
<LabeledSwitch <LabeledSwitch
checked={!!value.generateAssetsThumb} checked={!!value.generateAssetsThumb}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsThumb: checked }))} onChange={(checked) => setValue((v) => ({ ...v, generateAssetsThumb: checked }))}
label="Vorschaubild" label="Vorschaubild"
description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht." description="Erzeugt das statische Vorschaubild preview.jpg für Listen und Kartenansicht."
/> />
<LabeledSwitch <LabeledSwitch
checked={!!value.generateAssetsTeaser} checked={!!value.generateAssetsTeaser}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsTeaser: checked }))} onChange={(checked) => setValue((v) => ({ ...v, generateAssetsTeaser: checked }))}
label="Teaser" label="Teaser"
description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe." description="Erzeugt die kurze Video-Vorschau preview.mp4 für Hover- und Preview-Wiedergabe."
/> />
<LabeledSwitch <LabeledSwitch
checked={!!value.generateAssetsSprites} checked={!!value.generateAssetsSprites}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsSprites: checked }))} onChange={(checked) => setValue((v) => ({ ...v, generateAssetsSprites: checked }))}
label="Sprites" label="Sprites"
description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau." description="Erzeugt das Sprite-Bild preview-sprite.jpg für die Scrubber- und Timeline-Vorschau."
/> />
<LabeledSwitch <LabeledSwitch
checked={!!value.generateAssetsAnalyze} checked={!!value.generateAssetsAnalyze}
onChange={(checked) => setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))} onChange={(checked) => setValue((v) => ({ ...v, generateAssetsAnalyze: checked }))}
label="Analyse" label="Analyse"
description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten." description="Führt die Analyse aus und ergänzt Treffer, Segmente und weitere Auswertungsdaten."
/> />
</div> </div>
</div> </div>
<Task <Task
title="Downloads prüfen" 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." description="Prüft fertige Videos anhand der preview-sprite.jpg auf schwarze Endzellen und schreibt das Ergebnis in die meta.json."
startLabel="Prüfen" startLabel="Prüfen"
startingLabel="Starte…" startingLabel="Starte"
startUrl="/api/tasks/check-videos" startUrl="/api/tasks/check-videos"
onCreated={(job) => { onCreated={(job) => {
if (!job?.id) return if (!job?.id) return
@ -2326,10 +2329,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/> />
<Task <Task
title="Aufräumen" 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.' 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" startLabel="Aufräumen"
startingLabel="Läuft…" startingLabel="Läuft…"
onTrigger={cleanupSmallDone} onTrigger={cleanupSmallDone}
disabled={saving} disabled={saving}
onCreated={(job) => { onCreated={(job) => {
@ -2348,7 +2351,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
id="paths" id="paths"
title="Pfad-Einstellungen" title="Pfad-Einstellungen"
description="Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad." description="Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad."
icon="📁" icon="📁"
defaultOpen={false} defaultOpen={false}
> >
<div className="mb-3"> <div className="mb-3">
@ -2492,7 +2495,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
{databaseSettingsDirty ? ( {databaseSettingsDirty ? (
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200"> <div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern. Die Datenbank-Einstellungen wurden geändert. Bitte erst speichern.
</div> </div>
) : null} ) : null}
</div> </div>
@ -2632,14 +2635,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<SettingsSection <SettingsSection
id="automation" id="automation"
title="Automatisierung & Anzeige" title="Automatisierung & Anzeige"
description="Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen." description="Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen."
icon="🤖" icon="🤖"
defaultOpen={false} defaultOpen={false}
> >
<div className="mb-3"> <div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Automatisierung & Anzeige</div> <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"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen. Verhalten beim Hinzufügen/Starten sowie Anzeigeoptionen.
</div> </div>
</div> </div>
@ -2701,7 +2704,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})) }))
} }
label="EnrichQ-Postwork im Hintergrund" 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." 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 <LabeledSwitch
@ -2713,8 +2716,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false, autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false,
})) }))
} }
label="Clipboard-Links in Start-URL übernehmen" 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." description="Übernimmt erkannte Chaturbate- und MyFreeCams-Links aus der Zwischenablage automatisch in das Start-URL-Feld. Wenn deaktiviert, werden Clipboard-Links ignoriert."
/> />
<LabeledSwitch <LabeledSwitch
@ -2726,8 +2729,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoAddToDownloadList: checked ? true : v.autoAddToDownloadList, autoAddToDownloadList: checked ? true : v.autoAddToDownloadList,
})) }))
} }
label="Übernommene Links automatisch starten" 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." 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"> <div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
@ -2742,7 +2745,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})) }))
} }
label="Provider-APIs verwenden" 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." 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 <div
@ -2761,7 +2764,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})) }))
} }
label="Chaturbate API" 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." description="Aktualisiert Online-Status, Vorschaubilder und Model-Daten über die Chaturbate-API. Wenn Automatisch starten aktiv ist, werden öffentliche watched Models gestartet."
/> />
<LabeledSwitch <LabeledSwitch
@ -2774,12 +2777,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
})) }))
} }
label="MyFreeCams API" 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." 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>
</div> </div>
{/* ✅ NEU: Auto-Delete kleine Downloads */} {/* 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"> <div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch <LabeledSwitch
checked={!!value.autoDeleteSmallDownloads} checked={!!value.autoDeleteSmallDownloads}
@ -2791,8 +2794,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
v.autoDeleteSmallDownloadsBelowMB ?? 50, v.autoDeleteSmallDownloadsBelowMB ?? 50,
})) }))
} }
label="Kleine Downloads automatisch löschen" label="Kleine Downloads automatisch löschen"
description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist." description="Löscht fertige Downloads automatisch, wenn die Datei kleiner als die eingestellte Mindestgröße ist."
/> />
<div <div
@ -2802,8 +2805,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
> >
<div className="sm:col-span-4"> <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-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 className="text-xs text-gray-600 dark:text-gray-300">Alles darunter wird gelöscht.</div>
</div> </div>
<div className="sm:col-span-8"> <div className="sm:col-span-8">
@ -2842,8 +2845,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
autoDeleteSmallDownloadsKeepFavorites: checked, autoDeleteSmallDownloadsKeepFavorites: checked,
})) }))
} }
label="Favoriten nicht automatisch löschen" 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." 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> </div>
@ -2859,8 +2862,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
v.autoDeleteLowRatedDownloadsMaxStars ?? 3, v.autoDeleteLowRatedDownloadsMaxStars ?? 3,
})) }))
} }
label="Downloads nach Analyse nach Rating löschen" 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." description="Löscht fertige Downloads automatisch nach der Analyse, wenn das Rating höchstens der gewählten Sternezahl entspricht."
/> />
<div <div
@ -2874,7 +2877,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Sterne-Grenze Sterne-Grenze
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-300"> <div className="text-xs text-gray-600 dark:text-gray-300">
Bis einschließlich dieser Bewertung wird gelÃscht. Bis einschließlich dieser Bewertung wird gelöscht.
</div> </div>
</div> </div>
@ -2912,7 +2915,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Object Detection Epochs Object Detection Epochs
</div> </div>
<div className="max-w-4xl text-xs text-gray-600 dark:text-gray-300"> <div className="max-w-4xl text-xs text-gray-600 dark:text-gray-300">
Anzahl der Trainingsdurchläufe ¼r die Box-Erkennung. Mehr Epochs nnen die Erkennung verbessern, dauern aber länger. 60 ist ein guter Standard. Anzahl der Trainingsdurchläufe für die Box-Erkennung. Mehr Epochs nnen die Erkennung verbessern, dauern aber länger. 60 ist ein guter Standard.
</div> </div>
</div> </div>
@ -2946,7 +2949,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
checked={!!value.blurPreviews} checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))} onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}
label="Vorschaubilder blurren" label="Vorschaubilder blurren"
description="Weichzeichnet Vorschaubilder/Teaser (praktisch auf mobilen Geräten oder im öffentlichen Umfeld)." 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="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
@ -2955,7 +2958,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Teaser abspielen Teaser abspielen
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-300"> <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. Standbild spart Leistung. Bei Hover (Standard): Desktop spielt bei Hover ab, Mobile im Viewport. Alle kann viel CPU ziehen.
</div> </div>
</div> </div>
@ -3021,7 +3024,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div> <div>
<span className="font-medium">Schwelle:</span>{' '} <span className="font-medium">Schwelle:</span>{' '}
Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB
{' · '}Resume ab{' '} {' · '}Resume ab{' '}
<span className="tabular-nums"> <span className="tabular-nums">
{uiResumeGB} {uiResumeGB}
</span>{' '} </span>{' '}
@ -3031,7 +3034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
{diskStatus {diskStatus
? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}` ? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}`
: 'Status wird geladen…'} : 'Status wird geladen'}
</div> </div>
{diskStatus?.emergency && ( {diskStatus?.emergency && (
@ -3073,7 +3076,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
].join(' ')} ].join(' ')}
aria-hidden="true" aria-hidden="true"
> >
â¾
</span> </span>
</button> </button>
@ -3086,7 +3089,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Logdatei: {appLog.path} Logdatei: {appLog.path}
</span> </span>
) : appLogLoading ? ( ) : appLogLoading ? (
'Logdatei wird geladen…' 'Logdatei wird geladen'
) : ( ) : (
'Logdatei noch nicht geladen.' 'Logdatei noch nicht geladen.'
)} )}
@ -3098,7 +3101,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
disabled={appLogLoading} disabled={appLogLoading}
onClick={() => void loadAppLog()} onClick={() => void loadAppLog()}
> >
{appLogLoading ? 'Lade…' : 'Aktualisieren'} {appLogLoading ? 'Lade' : 'Aktualisieren'}
</Button> </Button>
</div> </div>
@ -3113,7 +3116,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
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" 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 {appLogLoading
? 'Log wird geladen…' ? 'Log wird geladen'
: appLog?.log?.trim() : appLog?.log?.trim()
? appLog.log ? appLog.log
: 'Noch kein Log vorhanden.'} : 'Noch kein Log vorhanden.'}
@ -3129,7 +3132,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
id="security" id="security"
title="Konto-Sicherheit" title="Konto-Sicherheit"
description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten." description="Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten."
icon="🛡️" icon="🛡️"
defaultOpen={false} defaultOpen={false}
actions={ actions={
<> <>
@ -3143,10 +3146,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-gray-900 dark:text-white"> <div className="text-sm font-semibold text-gray-900 dark:text-white">
Passwort ändern Passwort ändern
</div> </div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Ãndert das Login-Passwort. Andere Sessions werden abgemeldet. Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
</div> </div>
</div> </div>
</div> </div>
@ -3201,7 +3204,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Zwei-Faktor-Authentifizierung Zwei-Faktor-Authentifizierung
</div> </div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Authenticator-App als zweiter Faktor. Einzelne Geräte nnen bei TOTP nicht getrennt erkannt werden. Authenticator-App als zweiter Faktor. Einzelne Geräte nnen bei TOTP nicht getrennt erkannt werden.
</div> </div>
</div> </div>
@ -3211,7 +3214,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{authStatus?.totpDevice ? ( {authStatus?.totpDevice ? (
<SecurityDeviceRow <SecurityDeviceRow
icon="🔐" icon="🔐"
title={authStatus.totpDevice.label || 'Authenticator-App'} title={authStatus.totpDevice.label || 'Authenticator-App'}
subtitle={ subtitle={
authStatus?.totpEnabled authStatus?.totpEnabled
@ -3223,7 +3226,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/> />
) : ( ) : (
<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"> <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. 2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
</div> </div>
)} )}
</div> </div>
@ -3275,7 +3278,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
) : ( ) : (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400"> <div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Zum Deaktivieren brauchst du dein aktuelles Passwort Zum Deaktivieren brauchst du dein aktuelles Passwort
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'} {authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
</div> </div>
)} )}
@ -3300,7 +3303,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Passkeys Passkeys
</div> </div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <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. Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
</div> </div>
</div> </div>
@ -3312,20 +3315,20 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
(authStatus?.passkeys ?? []).map((pk, index) => ( (authStatus?.passkeys ?? []).map((pk, index) => (
<SecurityDeviceRow <SecurityDeviceRow
key={pk.id || index} key={pk.id || index}
icon="🔑" icon="🔑"
title={pk.label || `Passkey ${index + 1}`} title={pk.label || `Passkey ${index + 1}`}
subtitle={ subtitle={
pk.backupEligible pk.backupEligible
? pk.backupState ? pk.backupState
? 'Synchronisierter Passkey' ? 'Synchronisierter Passkey'
: 'Passkey kann synchronisiert werden' : 'Passkey kann synchronisiert werden'
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel' : 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
} }
meta={[ meta={[
`Erstellt: ${formatSecurityDate(pk.createdAt)}`, `Erstellt: ${formatSecurityDate(pk.createdAt)}`,
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`, `Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '', typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
].filter(Boolean).join(' · ')} ].filter(Boolean).join(' · ')}
badge="Passkey" badge="Passkey"
actions={ actions={
<Button <Button
@ -3335,7 +3338,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
disabled={securityBusy} disabled={securityBusy}
onClick={() => void deletePasskey(pk)} onClick={() => void deletePasskey(pk)}
> >
LÃschen Löschen
</Button> </Button>
} }
/> />
@ -3349,7 +3352,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
{!passkeySupported ? ( {!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"> <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. Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
</div> </div>
) : ( ) : (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
@ -3364,9 +3367,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-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(' ')} ].join(' ')}
> >
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null} {passkeyFeedback.kind === 'loading' ? ' ' : null}
{passkeyFeedback.kind === 'success' ? '✅ ' : null} {passkeyFeedback.kind === 'success' ? ' ' : null}
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null} {passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
{passkeyFeedback.text} {passkeyFeedback.text}
</div> </div>
) : null} ) : null}
@ -3377,7 +3380,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
disabled={securityBusy} disabled={securityBusy}
onClick={() => void registerSettingsPasskey()} onClick={() => void registerSettingsPasskey()}
> >
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'} {securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
</Button> </Button>
</div> </div>
</div> </div>