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:
@@ -61,8 +61,10 @@
|
||||
// `&&` (not `||`), else a one-half failure stays stuck on "wird erstellt…" forever and the
|
||||
// host never sees the "re-release" recovery hint.
|
||||
let exportGenerating = $derived(
|
||||
!!exportInfo?.released && !exportReady &&
|
||||
exportInfo?.zip?.status !== 'failed' && exportInfo?.html?.status !== 'failed'
|
||||
!!exportInfo?.released &&
|
||||
!exportReady &&
|
||||
exportInfo?.zip?.status !== 'failed' &&
|
||||
exportInfo?.html?.status !== 'failed'
|
||||
);
|
||||
let exportProgress = $derived(
|
||||
Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
|
||||
@@ -415,13 +417,23 @@
|
||||
<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
|
||||
<Modal open={pinModal !== null} titleId="host-pin-modal-title" onClose={() => (pinModal = null)}>
|
||||
{#if pinModal}
|
||||
<h2 id="host-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="host-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>
|
||||
@@ -437,11 +449,13 @@
|
||||
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||||
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
|
||||
{#if banTarget}
|
||||
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||
<h2 id="host-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 Hochladen, Liken und Kommentieren werden blockiert. Der
|
||||
Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“.
|
||||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus Galerie,
|
||||
Diashow und Export, und Hochladen, Liken und Kommentieren werden blockiert. Der Lesezugriff (Feed
|
||||
ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -463,11 +477,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>
|
||||
<div class="min-w-0">
|
||||
@@ -483,15 +503,22 @@
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
||||
<div
|
||||
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{:else if event}
|
||||
|
||||
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
|
||||
{#if pinResetRequests.length > 0}
|
||||
<div class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30"
|
||||
>
|
||||
<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
|
||||
<h2 class="font-semibold text-amber-900 dark:text-amber-200">
|
||||
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1 ? '' : 'n'}
|
||||
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1
|
||||
? ''
|
||||
: 'n'}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
|
||||
Prüfe die Identität, bevor du eine PIN zurücksetzt.
|
||||
@@ -500,7 +527,9 @@
|
||||
<ul class="divide-y divide-amber-200 dark:divide-amber-900">
|
||||
{#each pinResetRequests as req (req.id)}
|
||||
<li class="flex items-center justify-between gap-3 px-5 py-3">
|
||||
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100">{req.display_name}</span>
|
||||
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100"
|
||||
>{req.display_name}</span
|
||||
>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
onclick={() => dismissPinRequest(req)}
|
||||
@@ -522,7 +551,9 @@
|
||||
{/if}
|
||||
|
||||
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
onclick={() => (statsOpen = !statsOpen)}
|
||||
aria-expanded={statsOpen}
|
||||
@@ -530,30 +561,51 @@
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Statistiken</h2>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overflow-hidden transition-[max-height] duration-200 {statsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
||||
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4">
|
||||
<div
|
||||
class="overflow-hidden transition-[max-height] duration-200 {statsOpen
|
||||
? 'max-h-[500px]'
|
||||
: 'max-h-0'}"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4"
|
||||
>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{users.reduce((s, u) => s + u.upload_count, 0)}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||
<p class="text-2xl font-bold {event.uploads_locked ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
|
||||
<p
|
||||
class="text-2xl font-bold {event.uploads_locked
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-green-600 dark:text-green-400'}"
|
||||
>
|
||||
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-gray-500'}">
|
||||
<p
|
||||
class="text-2xl font-bold {event.export_released
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-400 dark:text-gray-500'}"
|
||||
>
|
||||
{event.export_released ? 'Ja' : 'Nein'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Freigegeben</p>
|
||||
@@ -563,7 +615,9 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
onclick={() => (settingsOpen = !settingsOpen)}
|
||||
aria-expanded={settingsOpen}
|
||||
@@ -571,32 +625,47 @@
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Event-Einstellungen</h2>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overflow-hidden transition-[max-height] duration-200 {settingsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
||||
<div
|
||||
class="overflow-hidden transition-[max-height] duration-200 {settingsOpen
|
||||
? 'max-h-[500px]'
|
||||
: 'max-h-0'}"
|
||||
>
|
||||
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700">
|
||||
<button
|
||||
onclick={toggleEventLock}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
||||
{event.uploads_locked ? 'bg-green-600 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-400' : 'bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400'}"
|
||||
{event.uploads_locked
|
||||
? 'bg-green-600 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-400'
|
||||
: 'bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400'}"
|
||||
>
|
||||
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
|
||||
</button>
|
||||
<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
|
||||
})}
|
||||
disabled={event.export_released}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
||||
{event.export_released ? 'cursor-default bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500' : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'}"
|
||||
{event.export_released
|
||||
? 'cursor-default bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'}"
|
||||
>
|
||||
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
||||
</button>
|
||||
@@ -608,14 +677,25 @@
|
||||
{#if event.export_released}
|
||||
<div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-gray-800/60">
|
||||
{#if exportGenerating}
|
||||
<p class="font-medium text-amber-700 dark:text-amber-300">Keepsake wird erstellt… {exportProgress}%</p>
|
||||
<div class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-full rounded-full bg-amber-500 transition-all" style="width: {exportProgress}%"></div>
|
||||
<p class="font-medium text-amber-700 dark:text-amber-300">
|
||||
Keepsake wird erstellt… {exportProgress}%
|
||||
</p>
|
||||
<div
|
||||
class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-amber-500 transition-all"
|
||||
style="width: {exportProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else if exportReady}
|
||||
<p class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-green-700 dark:text-green-300">Keepsake ist bereit.</span>
|
||||
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400">Zum Download</a>
|
||||
<span class="font-medium text-green-700 dark:text-green-300"
|
||||
>Keepsake ist bereit.</span
|
||||
>
|
||||
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
|
||||
>Zum Download</a
|
||||
>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
|
||||
@@ -656,8 +736,8 @@
|
||||
data-testid="export-rebuild"
|
||||
class="mt-2 rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:opacity-50
|
||||
{exportReady
|
||||
? 'text-gray-500 underline dark:text-gray-400'
|
||||
: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
|
||||
? 'text-gray-500 underline dark:text-gray-400'
|
||||
: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
|
||||
>
|
||||
{rebuilding
|
||||
? 'Wird gestartet…'
|
||||
@@ -671,7 +751,9 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
onclick={() => (usersOpen = !usersOpen)}
|
||||
aria-expanded={usersOpen}
|
||||
@@ -679,19 +761,40 @@
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Nutzerverwaltung</h2>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overflow-hidden transition-[max-height] duration-300 {usersOpen ? 'max-h-[9999px]' : 'max-h-0'}">
|
||||
<div
|
||||
class="overflow-hidden transition-[max-height] duration-300 {usersOpen
|
||||
? 'max-h-[9999px]'
|
||||
: 'max-h-0'}"
|
||||
>
|
||||
<div class="border-t border-gray-100 dark:border-gray-700">
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3">
|
||||
<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"
|
||||
@@ -702,25 +805,40 @@
|
||||
</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">
|
||||
@@ -729,18 +847,19 @@
|
||||
<!-- Only show Entsperren to someone allowed to act on this user (a plain
|
||||
host can't unban a peer host — that's an admin-only action, F1). -->
|
||||
{#if canModerate(user)}
|
||||
<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>
|
||||
<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>
|
||||
{/if}
|
||||
{:else if user.id !== myUserId}
|
||||
<!-- Never render target-actions (promote/demote/PIN/ban) on the
|
||||
@@ -749,13 +868,14 @@
|
||||
would only ever fail. -->
|
||||
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
||||
<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)
|
||||
})}
|
||||
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
|
||||
@@ -765,13 +885,14 @@
|
||||
<!-- Only an admin may demote a host (F1). A plain host demoting a
|
||||
peer host is a 403 on the backend, so the button is hidden. -->
|
||||
<button
|
||||
onclick={() => (confirmAction = {
|
||||
title: 'Zum Gast degradieren?',
|
||||
message: `${user.display_name} verliert alle Host-Rechte.`,
|
||||
confirmLabel: 'Degradieren',
|
||||
tone: 'danger',
|
||||
run: () => demoteToGuest(user)
|
||||
})}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user