chore(frontend): add ESLint + Prettier; fix real findings; format the tree
The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,8 +56,8 @@
|
||||
{
|
||||
title: 'Limits & Größen',
|
||||
fields: [
|
||||
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
|
||||
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
|
||||
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
|
||||
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
|
||||
// compression_concurrency is set via COMPRESSION_WORKER_CONCURRENCY at
|
||||
// boot, not live — omitted so it isn't a dead no-op control.
|
||||
]
|
||||
@@ -65,30 +65,50 @@
|
||||
{
|
||||
title: 'Rate-Limits',
|
||||
fields: [
|
||||
{ key: 'rate_limits_enabled', label: 'Rate-Limits aktiv', kind: 'bool', hint: 'Hauptschalter — wenn aus, sind alle Rate-Limits deaktiviert.' },
|
||||
{ key: 'upload_rate_enabled', label: 'Upload-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'feed_rate_enabled', label: 'Feed-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'export_rate_enabled', label: 'Export-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'join_rate_enabled', label: 'Join-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'upload_rate_per_hour', label: 'Upload-Limit pro Stunde', kind: 'number' },
|
||||
{ key: 'feed_rate_per_min', label: 'Feed-Anfragen pro Minute', kind: 'number' },
|
||||
{ key: 'export_rate_per_day', label: 'Export-Downloads pro Tag', kind: 'number' }
|
||||
{
|
||||
key: 'rate_limits_enabled',
|
||||
label: 'Rate-Limits aktiv',
|
||||
kind: 'bool',
|
||||
hint: 'Hauptschalter — wenn aus, sind alle Rate-Limits deaktiviert.'
|
||||
},
|
||||
{ key: 'upload_rate_enabled', label: 'Upload-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'feed_rate_enabled', label: 'Feed-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'export_rate_enabled', label: 'Export-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'join_rate_enabled', label: 'Join-Limit aktiv', kind: 'bool' },
|
||||
{ key: 'upload_rate_per_hour', label: 'Upload-Limit pro Stunde', kind: 'number' },
|
||||
{ key: 'feed_rate_per_min', label: 'Feed-Anfragen pro Minute', kind: 'number' },
|
||||
{ key: 'export_rate_per_day', label: 'Export-Downloads pro Tag', kind: 'number' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Quoten',
|
||||
fields: [
|
||||
{ key: 'quota_enabled', label: 'Quoten aktiv', kind: 'bool', hint: 'Hauptschalter — wenn aus, wird nichts geprüft.' },
|
||||
{ key: 'storage_quota_enabled', label: 'Speicher-Quote aktiv', kind: 'bool' },
|
||||
{ key: 'upload_count_quota_enabled', label: 'Upload-Anzahl-Quote aktiv', kind: 'bool', hint: 'Reserviert für künftige Anzahl-Limits.' },
|
||||
{ key: 'quota_tolerance', label: 'Toleranz (0–1)', kind: 'number' },
|
||||
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
|
||||
{
|
||||
key: 'quota_enabled',
|
||||
label: 'Quoten aktiv',
|
||||
kind: 'bool',
|
||||
hint: 'Hauptschalter — wenn aus, wird nichts geprüft.'
|
||||
},
|
||||
{ key: 'storage_quota_enabled', label: 'Speicher-Quote aktiv', kind: 'bool' },
|
||||
{
|
||||
key: 'upload_count_quota_enabled',
|
||||
label: 'Upload-Anzahl-Quote aktiv',
|
||||
kind: 'bool',
|
||||
hint: 'Reserviert für künftige Anzahl-Limits.'
|
||||
},
|
||||
{ key: 'quota_tolerance', label: 'Toleranz (0–1)', kind: 'number' },
|
||||
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Datenschutzhinweis',
|
||||
fields: [
|
||||
{ key: 'privacy_note', label: 'Datenschutzhinweis (freier Text)', kind: 'text', hint: 'Wird wörtlich im Konto-Bereich angezeigt. Kein HTML — Leerzeichen und Zeilenumbrüche werden übernommen.' }
|
||||
{
|
||||
key: 'privacy_note',
|
||||
label: 'Datenschutzhinweis (freier Text)',
|
||||
kind: 'text',
|
||||
hint: 'Wird wörtlich im Konto-Bereich angezeigt. Kein HTML — Leerzeichen und Zeilenumbrüche werden übernommen.'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -103,7 +123,12 @@
|
||||
}
|
||||
|
||||
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
||||
const TAB_LABELS: Record<AdminTab, string> = { stats: 'Stats', config: 'Config', export: 'Export', users: 'Nutzer' };
|
||||
const TAB_LABELS: Record<AdminTab, string> = {
|
||||
stats: 'Stats',
|
||||
config: 'Config',
|
||||
export: 'Export',
|
||||
users: 'Nutzer'
|
||||
};
|
||||
|
||||
let activeTab = $state<AdminTab>('stats');
|
||||
|
||||
@@ -215,7 +240,11 @@
|
||||
// Don't let a cleared number field persist as an empty/NaN config value.
|
||||
for (const key of NUMBER_KEYS) {
|
||||
const v = configDraft[key];
|
||||
if (v !== undefined && v !== config[key] && (String(v).trim() === '' || !Number.isFinite(Number(v)))) {
|
||||
if (
|
||||
v !== undefined &&
|
||||
v !== config[key] &&
|
||||
(String(v).trim() === '' || !Number.isFinite(Number(v)))
|
||||
) {
|
||||
toastError(new Error('Bitte gib für alle Zahlenfelder einen gültigen Wert ein.'));
|
||||
return;
|
||||
}
|
||||
@@ -350,20 +379,29 @@
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'done': return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200';
|
||||
case 'running': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
|
||||
case 'failed': return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
|
||||
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||
case 'done':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200';
|
||||
case 'running':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'pending': return 'Ausstehend';
|
||||
case 'running': return 'Läuft';
|
||||
case 'done': return 'Fertig';
|
||||
case 'failed': return 'Fehlgeschlagen';
|
||||
default: return status;
|
||||
case 'pending':
|
||||
return 'Ausstehend';
|
||||
case 'running':
|
||||
return 'Läuft';
|
||||
case 'done':
|
||||
return 'Fertig';
|
||||
case 'failed':
|
||||
return 'Fehlgeschlagen';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -395,13 +433,23 @@
|
||||
<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
|
||||
<Modal open={pinModal !== null} titleId="admin-pin-modal-title" onClose={() => (pinModal = null)}>
|
||||
{#if pinModal}
|
||||
<h2 id="admin-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
|
||||
<h2 id="admin-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Neue PIN für {pinModal.name}
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
|
||||
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie
|
||||
verworfen.
|
||||
</p>
|
||||
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
|
||||
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
|
||||
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60">
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30"
|
||||
>
|
||||
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||
>{pinModal.pin}</span
|
||||
>
|
||||
<button
|
||||
onclick={copyPinModal}
|
||||
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
@@ -417,14 +465,24 @@
|
||||
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||||
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
|
||||
{#if banTarget}
|
||||
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Benutzer sperren
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
||||
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus Galerie,
|
||||
Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button>
|
||||
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">
|
||||
<button
|
||||
onclick={() => (banTarget = null)}
|
||||
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800"
|
||||
>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
onclick={confirmBan}
|
||||
disabled={banSubmitting}
|
||||
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400"
|
||||
>
|
||||
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -433,11 +491,17 @@
|
||||
|
||||
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900">
|
||||
<div
|
||||
class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||||
<IconButton label="Zurück" onclick={() => goto('/account')} class="shrink-0">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
</IconButton>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Admin-Dashboard</h1>
|
||||
@@ -445,13 +509,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Inner tab bar -->
|
||||
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<div
|
||||
class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<div class="mx-auto flex max-w-3xl min-w-max">
|
||||
{#each Object.entries(TAB_LABELS) as [tab, label]}
|
||||
{#each Object.entries(TAB_LABELS) as [tab, label] (tab)}
|
||||
<button
|
||||
onclick={() => (activeTab = tab as AdminTab)}
|
||||
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||
{activeTab === tab ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
{activeTab === tab
|
||||
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -463,55 +531,95 @@
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||
{:else if error}
|
||||
<div role="alert" class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">{error}</div>
|
||||
<div
|
||||
role="alert"
|
||||
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
<!-- ── Stats tab ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-3">
|
||||
{#if stats}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.user_count}</p>
|
||||
<div
|
||||
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{stats.user_count}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.upload_count}</p>
|
||||
<div
|
||||
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{stats.upload_count}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.comment_count}</p>
|
||||
<div
|
||||
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{stats.comment_count}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{diskPct(stats)} %</p>
|
||||
<div
|
||||
class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{diskPct(stats)} %
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Speicher</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Disk bar -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>Speicherauslastung</span>
|
||||
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span>
|
||||
<span
|
||||
>{formatBytes(stats.disk_used_bytes)} / {formatBytes(
|
||||
stats.disk_total_bytes
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
|
||||
class="h-full rounded-full transition-all {diskPct(stats) >= 90
|
||||
? 'bg-red-500'
|
||||
: diskPct(stats) >= 75
|
||||
? 'bg-amber-500'
|
||||
: 'bg-blue-500'}"
|
||||
style="width: {diskPct(stats)}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{formatBytes(stats.disk_free_bytes)} frei</p>
|
||||
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatBytes(stats.disk_free_bytes)} frei
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'config'}
|
||||
<div class="relative space-y-3 pb-20">
|
||||
{#each CONFIG_GROUPS as group (group.title)}
|
||||
<div class="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{group.title}</h3>
|
||||
<h3
|
||||
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
{#each group.fields as field (field.key)}
|
||||
@@ -526,14 +634,20 @@
|
||||
onchange={() => toggleBool(field.key)}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{field.label}</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>{field.label}</span
|
||||
>
|
||||
{#if field.hint}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{:else if field.kind === 'text'}
|
||||
<label for={field.key} class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{field.label}</label>
|
||||
<label
|
||||
for={field.key}
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>{field.label}</label
|
||||
>
|
||||
<textarea
|
||||
id={field.key}
|
||||
rows="6"
|
||||
@@ -544,7 +658,11 @@
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<label for={field.key} class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{field.label}</label>
|
||||
<label
|
||||
for={field.key}
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>{field.label}</label
|
||||
>
|
||||
<input
|
||||
id={field.key}
|
||||
type="number"
|
||||
@@ -565,7 +683,9 @@
|
||||
{/each}
|
||||
|
||||
<!-- Sticky save button -->
|
||||
<div class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl">
|
||||
<div
|
||||
class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl"
|
||||
>
|
||||
<button
|
||||
onclick={saveConfig}
|
||||
disabled={saving}
|
||||
@@ -576,20 +696,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Export tab ───────────────────────────────────────────────── -->
|
||||
<!-- ── Export tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'export'}
|
||||
<div class="space-y-3">
|
||||
<!-- Gallery release -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
|
||||
<button
|
||||
onclick={() => (confirmAction = {
|
||||
title: 'Galerie freigeben?',
|
||||
message: 'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
|
||||
confirmLabel: 'Freigeben',
|
||||
tone: 'danger',
|
||||
run: releaseGallery
|
||||
})}
|
||||
onclick={() =>
|
||||
(confirmAction = {
|
||||
title: 'Galerie freigeben?',
|
||||
message:
|
||||
'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
|
||||
confirmLabel: 'Freigeben',
|
||||
tone: 'danger',
|
||||
run: releaseGallery
|
||||
})}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
Galerie freigeben
|
||||
@@ -597,7 +721,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Export jobs -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3>
|
||||
<button
|
||||
@@ -612,21 +738,34 @@
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Noch keine Export-Jobs.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each exportJobs as job}
|
||||
{#each exportJobs as job (job.type)}
|
||||
<div class="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{jobLabel(job.type)}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>{jobLabel(job.type)}</span
|
||||
>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(
|
||||
job.status
|
||||
)}"
|
||||
>
|
||||
{statusLabel(job.status)}
|
||||
</span>
|
||||
</div>
|
||||
{#if job.status === 'running'}
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div
|
||||
class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>Fortschritt</span><span>{job.progress_pct} %</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {job.progress_pct}%"></div>
|
||||
<div
|
||||
class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all"
|
||||
style="width: {job.progress_pct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -640,14 +779,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
||||
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'users'}
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<!-- Search -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
@@ -658,73 +811,106 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if filteredUsers.length === 0}
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine Treffer.</p>
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
Keine Treffer.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{#each filteredUsers as user}
|
||||
{#each filteredUsers as user (user.id)}
|
||||
<div class="flex items-center gap-3 px-5 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{user.display_name}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100"
|
||||
>{user.display_name}</span
|
||||
>
|
||||
{#if user.role === 'host'}
|
||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">Host</span>
|
||||
<span
|
||||
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
||||
>Host</span
|
||||
>
|
||||
{:else if user.role === 'admin'}
|
||||
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">Admin</span>
|
||||
<span
|
||||
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200"
|
||||
>Admin</span
|
||||
>
|
||||
{/if}
|
||||
{#if user.is_banned}
|
||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200">Gesperrt</span>
|
||||
<span
|
||||
class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200"
|
||||
>Gesperrt</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
|
||||
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(
|
||||
user.total_upload_bytes
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{#if user.role !== 'admin'}
|
||||
{#if user.is_banned}
|
||||
<button onclick={() => (confirmAction = {
|
||||
title: 'Sperre aufheben?',
|
||||
message: `${user.display_name} kann danach wieder hochladen, liken und kommentieren.`,
|
||||
confirmLabel: 'Entsperren',
|
||||
tone: 'default',
|
||||
run: () => unban(user)
|
||||
})} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||
Entsperren
|
||||
</button>
|
||||
{:else}
|
||||
{#if user.role === 'guest'}
|
||||
<button onclick={() => (confirmAction = {
|
||||
title: 'Zum Host befördern?',
|
||||
message: `${user.display_name} erhält Host-Rechte: sperren, PIN zurücksetzen und Galerie verwalten. Das lässt sich nur durch Degradieren rückgängig machen.`,
|
||||
confirmLabel: 'Befördern',
|
||||
tone: 'default',
|
||||
run: () => promoteToHost(user)
|
||||
})} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60">
|
||||
Host
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{#if user.role !== 'admin'}
|
||||
{#if user.is_banned}
|
||||
<button
|
||||
onclick={() =>
|
||||
(confirmAction = {
|
||||
title: 'Sperre aufheben?',
|
||||
message: `${user.display_name} kann danach wieder hochladen, liken und kommentieren.`,
|
||||
confirmLabel: 'Entsperren',
|
||||
tone: 'default',
|
||||
run: () => unban(user)
|
||||
})}
|
||||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Entsperren
|
||||
</button>
|
||||
{:else}
|
||||
{#if user.role === 'guest'}
|
||||
<button
|
||||
onclick={() =>
|
||||
(confirmAction = {
|
||||
title: 'Zum Host befördern?',
|
||||
message: `${user.display_name} erhält Host-Rechte: sperren, PIN zurücksetzen und Galerie verwalten. Das lässt sich nur durch Degradieren rückgängig machen.`,
|
||||
confirmLabel: 'Befördern',
|
||||
tone: 'default',
|
||||
run: () => promoteToHost(user)
|
||||
})}
|
||||
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
|
||||
>
|
||||
Host
|
||||
</button>
|
||||
{/if}
|
||||
{#if user.role === 'host'}
|
||||
<button
|
||||
onclick={() =>
|
||||
(confirmAction = {
|
||||
title: 'Zum Gast degradieren?',
|
||||
message: `${user.display_name} verliert alle Host-Rechte.`,
|
||||
confirmLabel: 'Degradieren',
|
||||
tone: 'danger',
|
||||
run: () => demoteToGuest(user)
|
||||
})}
|
||||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
Degradieren
|
||||
</button>
|
||||
{/if}
|
||||
{#if canResetPinFor(user)}
|
||||
<button
|
||||
onclick={() => askResetPin(user)}
|
||||
class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
|
||||
>
|
||||
PIN zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => openBanModal(user)}
|
||||
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
|
||||
>
|
||||
Sperren
|
||||
</button>
|
||||
{/if}
|
||||
{#if user.role === 'host'}
|
||||
<button onclick={() => (confirmAction = {
|
||||
title: 'Zum Gast degradieren?',
|
||||
message: `${user.display_name} verliert alle Host-Rechte.`,
|
||||
confirmLabel: 'Degradieren',
|
||||
tone: 'danger',
|
||||
run: () => demoteToGuest(user)
|
||||
})} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||
Degradieren
|
||||
</button>
|
||||
{/if}
|
||||
{#if canResetPinFor(user)}
|
||||
<button onclick={() => askResetPin(user)} class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
|
||||
PIN zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => openBanModal(user)} class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60">
|
||||
Sperren
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user