Fifth round, all storage. The keepsake could not fit on the documented hardware
and failed halfway through a multi-GB write, leaving the deliverable stuck; the
quota had stopped bounding the disk; and the backup had no restore procedure.
Squashed from 6 commits, original messages preserved below.
──────── fix(export): refuse an export that cannot fit, and stop peaking at two generations
Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.
The failure is not "the export failed", it is "the deliverable is stuck":
1. ENOSPC lands partway through a multi-GB write.
2. The epoch has already moved, so the job row is `failed` at the CURRENT
generation and readiness (epoch = event.export_epoch AND status = 'done') is
false -- GET /export/zip 404s.
3. The last good archive sits on disk, unreferenced and unreachable.
4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.
Three changes.
Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.
Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.
Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".
Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.
──────── fix(maintenance): reclaim the media of deliberately deleted uploads
The quota stopped bounding the disk. `soft_delete_in_event` stamps `deleted_at` and
refunds `total_upload_bytes`, but nothing ever removed the bytes, and the hourly
sweep reached only `compression_status = 'failed'`. Upload 500 MB, delete, quota back
to zero, upload another 500 MB. Not an attack -- a guest curating their camera roll,
which is what people do. The host then sees guests hitting "Du hast dein Upload-Limit
erreicht" while the admin widget shows a disk full of files no upload row points at,
and the quota message is actively misleading because the space really is gone, just
not to anyone the accounting can name.
Two retention windows, because the two deletes mean different things. A compression
failure keeps its 14 days: the guest didn't ask for it and may not be able to retake
the photo. A deliberate removal gets 24 hours -- 14 days outlives the whole event, so
a deliberate delete would never reclaim anything while it mattered, and a day still
covers a mis-tap.
Wider than reported: ALL FOUR paths are reclaimed, not just the original. Preview,
display and thumbnail are each a separate file, none counted in
`original_size_bytes`, and nothing ever removed them either. That was invisible while
the sweep only saw failed compressions (which produce no derivatives) and becomes
three leaked files per upload the moment it reaches a successful one. A row is
re-selected until every path is cleared, and the columns are cleared only once every
file for that upload is gone -- clearing after a partial success would strand the
survivors in exactly the unowned state this drains.
`backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT
NULL`, which is close enough to the post-sweep state to be worth pinning: it is
guarded on `deleted_at IS NULL`, so it cannot re-decode an original that is no longer
on disk. Covered.
Residual, deliberately: within the 24h window the bytes are still spent and still
unaccounted, so delete-and-re-upload through an eight-hour event can outrun the
sweep. Bounding that means holding the quota until the file is reclaimed rather than
refunding at `deleted_at`. The low-disk warning is the net under it.
Tests: 6 DB-backed, replacing 3. The one asserting an owner-deleted upload IS
reclaimed is the exact inverse of what this file used to assert.
──────── feat(host): warn about low disk before it becomes unrecoverable
Storage visibility existed in exactly one place: a passive Speicherauslastung widget
on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing
warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1.
Two things make this a safety net rather than a nice-to-have. postgres_data,
media_data and exports_data are all Docker named volumes on ONE filesystem, so
running out doesn't degrade a subsystem -- Postgres stops being able to write and the
whole event goes down. And the keepsake needs room for two gallery-sized archives,
which the export preflight can only ever refuse AFTER the release, when the event is
over and every remedy is harder.
So the threshold is not a fixed number alone. It fires on the 10 GB floor the README
always named, OR on "you could not build the keepsake right now" -- the trigger a
host can still act on, computed with the same arithmetic the preflight uses. Unknown
free space is NOT low: it fails open like the upload quota and the preflight do,
because a banner that cries wolf on an unreadable mount is a banner nobody reads.
Carried on GET /host/event, which the dashboard already fetches on load and on every
reload -- no new endpoint, no new poll. Rendered above everything else including the
PIN-reset queue, and it names the consequence (the event, not just the download)
rather than only the number.
Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have
rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown
that way in the user list.
Tests: 5 unit on the threshold (including that plenty of free space is still low when
the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e.
The e2e drives it through `original_size_bytes` rather than a genuinely full disk:
the estimate is pure SQL over that column, so overstating one row moves the
accounting without touching a byte on disk.
──────── docs: add a restore procedure, fix the backup cadence, and correct quota_tolerance
Four things, all found by the same question: what does an operator standing at the
venue actually need?
A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.
Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:
- The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
"_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
so the documented dump could only ever be restored into an empty database. Fixed
at the source (the dump is now self-cleaning) and verified end to end: 16 tables
back, exit 0.
- `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
the extract aborted before unpacking anything. `--numeric-owner` plus the
explicit chown, verified to land 100:101.
BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.
quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.
SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.
Also ticks the low-disk alert off the roadmap, since it now exists.
──────── chore: raise the db memory limit and rate-limit social writes
Two smaller operational items.
POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 30 for a ~100-guest event (feed
polling + SSE + uploads at once), and 30 backends plus Postgres 16's default
shared_buffers leaves very little headroom at 512M. An OOM here doesn't degrade one
feature -- every request path touches the database, so it takes the event down.
Memory is the cheaper knob than shrinking the pool back and reintroducing the
queueing it was raised to fix. .env.example now names the pairing explicitly, the way
it already does for COMPRESSION_WORKER_CONCURRENCY.
SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the
only mutating endpoints in the app with no limit at all -- upload, join, recover,
export and admin login all carry one. Asymmetric coverage rather than a deliberate
decision.
Low severity, and honestly so: a like fans an SSE broadcast to every client, but the
export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s,
workers born with their epoch, superseded ones inert). So the ceiling is 120/min --
far above anything a real guest produces. This bounds a script, not an enthusiastic
double-tapper.
ONE bucket across all three actions: separate buckets would let a caller triple the
aggregate write rate by alternating between them. Keyed per USER, matching the feed
and upload limits -- at a venue every guest is behind one NAT, and an IP key is what
made the /join and /feed limits turn guests away in the first place.
Migration 020 seeds both keys, and both are wired into the admin allowlist, the
config UI and the e2e reseed -- the step two earlier per-area toggles missed, which
left switches that existed in code and could never be flipped.
Tests: 4 e2e, including that the shared bucket really is shared (the part most likely
to be lost in a refactor) and that one guest hitting the ceiling doesn't block
another behind the same IP.
──────── fix(e2e): stop the video poster assertion racing the ffmpeg thumbnail
Pre-existing, and it fired for real during the full-suite run on a cold stack.
The lightbox binds `poster={upload.thumbnail_url ?? undefined}`, so the attribute is
absent until compression produces the thumbnail. This test asserted on it immediately
after seeding, never waiting for the worker -- unlike the Range test further down the
same file, which does poll. Against a warm stack the worker usually wins; against a
freshly rebuilt one (`stack:down -v`, cold ffmpeg) it doesn't.
That is the worst possible time for a false failure: the first run after a rebuild is
exactly when you are trying to establish whether a change broke something. Poll for
`compression_status = 'done'` before the poster assertion. The `src` assertion needs
no wait and keeps none.
Verified with --repeat-each=3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
958 lines
36 KiB
Svelte
958 lines
36 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import { getToken, getUserId } from '$lib/auth';
|
|
import { role as myRoleStore } from '$lib/role-store';
|
|
import { api } from '$lib/api';
|
|
import type { MeContextDto } from '$lib/types';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { onSseEvent } from '$lib/sse';
|
|
import { toast, toastError } from '$lib/toast-store';
|
|
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import { commentsEnabled } from '$lib/event-config-store';
|
|
|
|
interface UserSummary {
|
|
id: string;
|
|
display_name: string;
|
|
role: string;
|
|
is_banned: boolean;
|
|
uploads_hidden: boolean;
|
|
upload_count: number;
|
|
total_upload_bytes: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface EventStatus {
|
|
name: string;
|
|
is_active: boolean;
|
|
uploads_locked: boolean;
|
|
export_released: boolean;
|
|
disk_free_bytes: number | null;
|
|
keepsake_required_bytes: number;
|
|
disk_low: boolean;
|
|
}
|
|
|
|
interface PinResetRequest {
|
|
id: string;
|
|
user_id: string;
|
|
display_name: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ExportJob {
|
|
status: string;
|
|
progress_pct: number;
|
|
error_message: string | null;
|
|
}
|
|
interface ExportStatusDto {
|
|
released: boolean;
|
|
zip: ExportJob | null;
|
|
html: ExportJob | null;
|
|
}
|
|
|
|
let event = $state<EventStatus | null>(null);
|
|
let users = $state<UserSummary[]>([]);
|
|
let pinResetRequests = $state<PinResetRequest[]>([]);
|
|
let exportInfo = $state<ExportStatusDto | null>(null);
|
|
let rebuilding = $state(false);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
|
|
// Live keepsake state for the host dashboard: are both halves done, and if not, how far.
|
|
let exportReady = $derived(
|
|
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
|
|
);
|
|
// The keepsake needs BOTH halves, so EITHER failing means the whole thing failed — use
|
|
// `&&` (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'
|
|
);
|
|
let exportProgress = $derived(
|
|
Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
|
|
);
|
|
// Either half can carry the reason, and a disk failure usually fails both with the same text —
|
|
// so take the first one present rather than rendering it twice.
|
|
let exportError = $derived(
|
|
exportInfo?.zip?.error_message ?? exportInfo?.html?.error_message ?? null
|
|
);
|
|
|
|
// SSE unsubscribers, torn down on destroy.
|
|
let sseOff: Array<() => void> = [];
|
|
|
|
// Collapsible section state
|
|
let statsOpen = $state(true);
|
|
let settingsOpen = $state(true);
|
|
let usersOpen = $state(true);
|
|
|
|
// User search
|
|
let userSearch = $state('');
|
|
let filteredUsers = $derived(
|
|
userSearch.trim()
|
|
? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
|
|
: users
|
|
);
|
|
|
|
// Ban modal state
|
|
let banTarget = $state<UserSummary | null>(null);
|
|
let banSubmitting = $state(false);
|
|
|
|
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
|
|
// shown once and forgotten on close.
|
|
let pinResetTarget = $state<UserSummary | null>(null);
|
|
let pinResetSubmitting = $state(false);
|
|
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
|
|
|
// Live role, not the frozen JWT claim: a demotion must disable these controls at once.
|
|
const myRole = $derived($myRoleStore);
|
|
const myUserId = getUserId();
|
|
|
|
// Generic confirm-then-run for the irreversible / privilege-changing actions
|
|
// (promote, demote, unban, release gallery) that previously fired on one tap.
|
|
// Reuses the shared ConfirmSheet; the wrapped fns keep their own toast/reload.
|
|
interface PendingConfirm {
|
|
title: string;
|
|
message: string;
|
|
confirmLabel: string;
|
|
tone: 'default' | 'danger';
|
|
run: () => Promise<void>;
|
|
}
|
|
let confirmAction = $state<PendingConfirm | null>(null);
|
|
|
|
async function runConfirmAction() {
|
|
const action = confirmAction;
|
|
if (!action) return;
|
|
await action.run();
|
|
confirmAction = null;
|
|
}
|
|
|
|
/**
|
|
* Mirrors the backend authorisation rules shared by ban / unban / PIN-reset / set-role:
|
|
* a plain host may only act on GUESTS; only an admin may act on a host; nobody may act on
|
|
* an admin. Used to hide moderation buttons that would always 403 — a non-admin host must
|
|
* not see Sperren/Degradieren/PIN on a peer-host row (they'd tap it and get a bare 403).
|
|
*/
|
|
function canModerate(target: UserSummary): boolean {
|
|
if (target.role === 'admin') return false;
|
|
if (myRole === 'admin') return true;
|
|
if (myRole === 'host') return target.role === 'guest';
|
|
return false;
|
|
}
|
|
|
|
onMount(async () => {
|
|
const token = getToken();
|
|
if (!token) {
|
|
goto('/join');
|
|
return;
|
|
}
|
|
// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
|
|
// stale 'host' in the token, but the backend now 403s every host call. Fetch
|
|
// the current role and bounce a demoted host to the feed instead of leaving
|
|
// them on a dashboard that errors on load.
|
|
try {
|
|
const ctx = await api.get<MeContextDto>('/me/context');
|
|
if (ctx.role !== 'host' && ctx.role !== 'admin') {
|
|
goto('/feed');
|
|
return;
|
|
}
|
|
} catch {
|
|
// Expired/invalid session (api.ts cleared it) — send them to re-auth.
|
|
goto('/join');
|
|
return;
|
|
}
|
|
await reload();
|
|
|
|
// The awaits above mean the component can already be destroyed by the time we get
|
|
// here (user navigated away mid-load). Svelte doesn't cancel an async onMount, so
|
|
// without this guard onDestroy would have run with an empty `sseOff` and we'd leak
|
|
// these handlers into the module-global SSE map forever.
|
|
if (destroyed) return;
|
|
|
|
// Live updates so the dashboard doesn't need a manual reload:
|
|
// - pin-reset-requested: a guest just asked for a reset → refresh the badge/list.
|
|
// - pin-reset: some host resolved a reset → drop it from the list (two-host race:
|
|
// the other host's stale row disappears instead of yielding a conflicting PIN).
|
|
// - export-progress/-available: keepsake generation moves → refresh the status line.
|
|
sseOff = [
|
|
onSseEvent('pin-reset-requested', () => void refreshPinRequests()),
|
|
onSseEvent('pin-reset', () => void refreshPinRequests()),
|
|
onSseEvent('export-progress', () => void refreshExportStatus()),
|
|
onSseEvent('export-available', () => void refreshExportStatus())
|
|
];
|
|
});
|
|
|
|
let destroyed = false;
|
|
onDestroy(() => {
|
|
destroyed = true;
|
|
for (const off of sseOff) off();
|
|
});
|
|
|
|
async function reload() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
[event, users, pinResetRequests] = await Promise.all([
|
|
api.get<EventStatus>('/host/event'),
|
|
api.get<UserSummary[]>('/host/users'),
|
|
api.get<PinResetRequest[]>('/host/pin-reset-requests')
|
|
]);
|
|
} catch (e: unknown) {
|
|
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
// Export status is a secondary widget — fetch it OUTSIDE the all-or-nothing block above
|
|
// (and error-swallowing) so a transient /export/status failure can't blank the whole
|
|
// dashboard (users, moderation, settings).
|
|
void refreshExportStatus();
|
|
}
|
|
|
|
/** Refetch just the pending PIN-reset requests (live badge; avoids a full-page reload). */
|
|
async function refreshPinRequests() {
|
|
try {
|
|
pinResetRequests = await api.get<PinResetRequest[]>('/host/pin-reset-requests');
|
|
} catch {
|
|
/* non-fatal — the next manual reload picks it up */
|
|
}
|
|
}
|
|
|
|
/** Refetch just the keepsake export status (live progress/ready). */
|
|
async function refreshExportStatus() {
|
|
try {
|
|
exportInfo = await api.get<ExportStatusDto>('/export/status');
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
async function resetPinForRequest(req: PinResetRequest) {
|
|
try {
|
|
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
|
|
pinModal = { name: req.display_name, pin: res.pin };
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function dismissPinRequest(req: PinResetRequest) {
|
|
try {
|
|
await api.delete(`/host/pin-reset-requests/${req.id}`);
|
|
pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function reopenEvent() {
|
|
try {
|
|
await api.post('/host/event/open');
|
|
toast('Uploads wurden wieder geöffnet.', 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
function toggleEventLock() {
|
|
if (!event) return;
|
|
if (event.uploads_locked) {
|
|
// Reopening after a release INVALIDATES the published keepsake: it clears the
|
|
// release + ready flags, so every guest's download 404s until the host re-releases.
|
|
// That's destructive and easy to trigger by accident ("let a few more photos in"),
|
|
// so gate it behind a danger confirm — but a plain lock (not yet released) reopens
|
|
// with no ceremony.
|
|
if (event.export_released) {
|
|
confirmAction = {
|
|
title: 'Galerie-Freigabe zurücknehmen?',
|
|
message:
|
|
'Wenn du die Uploads wieder öffnest, wird die veröffentlichte Galerie zurückgezogen. ' +
|
|
'Gäste können das Keepsake erst nach einer erneuten Freigabe wieder herunterladen.',
|
|
confirmLabel: 'Wieder öffnen',
|
|
tone: 'danger',
|
|
run: reopenEvent
|
|
};
|
|
return;
|
|
}
|
|
void reopenEvent();
|
|
} else {
|
|
void (async () => {
|
|
try {
|
|
await api.post('/host/event/close');
|
|
toast('Uploads wurden gesperrt.', 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
|
|
async function releaseGallery() {
|
|
try {
|
|
await api.post('/host/gallery/release');
|
|
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
// Release marks `export_released_at` before enqueuing the workers; if that second
|
|
// step errored the event is already released, so reconcile the UI (otherwise the
|
|
// button still reads "Galerie freigeben" and a retry just 409s "bereits freigegeben").
|
|
await reload();
|
|
}
|
|
}
|
|
|
|
// The escape hatch for a keepsake that is failed or stale. Without this the only recovery is
|
|
// reopening uploads (which unlocks the gallery to every guest and retracts the release) or a
|
|
// container restart — neither of which a host at 2am can reasonably be asked to do.
|
|
async function rebuildExport() {
|
|
rebuilding = true;
|
|
try {
|
|
await api.post('/host/export/rebuild', {});
|
|
toast('Keepsake wird neu erstellt…', 'success');
|
|
await refreshExportStatus();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
} finally {
|
|
rebuilding = false;
|
|
}
|
|
}
|
|
|
|
function openBanModal(user: UserSummary) {
|
|
banTarget = user;
|
|
}
|
|
|
|
async function confirmBan() {
|
|
if (!banTarget) return;
|
|
banSubmitting = true;
|
|
try {
|
|
await api.post(`/host/users/${banTarget.id}/ban`, {});
|
|
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
|
banTarget = null;
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
} finally {
|
|
banSubmitting = false;
|
|
}
|
|
}
|
|
|
|
async function unban(user: UserSummary) {
|
|
try {
|
|
await api.post(`/host/users/${user.id}/unban`);
|
|
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function promoteToHost(user: UserSummary) {
|
|
try {
|
|
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
|
|
toast(`${user.display_name} ist jetzt Host.`, 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
async function demoteToGuest(user: UserSummary) {
|
|
try {
|
|
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
|
|
toast(`${user.display_name} ist jetzt Gast.`, 'success');
|
|
await reload();
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
}
|
|
}
|
|
|
|
function askResetPin(user: UserSummary) {
|
|
pinResetTarget = user;
|
|
}
|
|
|
|
async function confirmResetPin() {
|
|
if (!pinResetTarget) return;
|
|
pinResetSubmitting = true;
|
|
try {
|
|
const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
|
|
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
|
|
pinResetTarget = null;
|
|
} catch (e: unknown) {
|
|
toastError(e);
|
|
} finally {
|
|
pinResetSubmitting = false;
|
|
}
|
|
}
|
|
|
|
function copyPinModal() {
|
|
if (!pinModal) return;
|
|
navigator.clipboard.writeText(pinModal.pin);
|
|
toast('PIN kopiert.', 'success');
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
// GB matters here now that this also renders free disk and keepsake size — the previous
|
|
// version topped out at MB, so 30 GB free read as "30720.0 MB" (and a guest with 2 GB of
|
|
// uploads was already being rendered the same way in the user list).
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
}
|
|
</script>
|
|
|
|
<!-- Confirmation for irreversible / privilege-changing actions (promote/demote/unban/release). -->
|
|
<ConfirmSheet
|
|
open={confirmAction !== null}
|
|
title={confirmAction?.title ?? ''}
|
|
message={confirmAction?.message ?? ''}
|
|
confirmLabel={confirmAction?.confirmLabel ?? 'Bestätigen'}
|
|
tone={confirmAction?.tone ?? 'default'}
|
|
onConfirm={runConfirmAction}
|
|
onCancel={() => (confirmAction = null)}
|
|
/>
|
|
|
|
<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
|
|
<ConfirmSheet
|
|
open={pinResetTarget !== null}
|
|
title="PIN zurücksetzen"
|
|
message={pinResetTarget
|
|
? `Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr.`
|
|
: ''}
|
|
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
|
|
tone="danger"
|
|
onConfirm={confirmResetPin}
|
|
onCancel={() => (pinResetTarget = null)}
|
|
/>
|
|
|
|
<!-- 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>
|
|
<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.
|
|
</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"
|
|
>
|
|
Kopieren
|
|
</button>
|
|
</div>
|
|
<button onclick={() => (pinModal = null)} class="btn btn-primary btn-block"> Schließen </button>
|
|
{/if}
|
|
</Modal>
|
|
|
|
<!-- 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>
|
|
<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{$commentsEnabled ? ' 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 onclick={() => (banTarget = null)} class="btn btn-secondary flex-1">
|
|
Abbrechen
|
|
</button>
|
|
<button onclick={confirmBan} disabled={banSubmitting} class="btn btn-danger flex-1">
|
|
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</Modal>
|
|
|
|
<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="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"
|
|
/>
|
|
</svg>
|
|
</IconButton>
|
|
<div class="min-w-0">
|
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Host-Dashboard</h1>
|
|
{#if event}
|
|
<p class="truncate text-sm text-gray-500 dark:text-gray-400">{event.name}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mx-auto max-w-3xl space-y-3 p-4">
|
|
{#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>
|
|
{:else if event}
|
|
<!-- ── Speicherwarnung ─────────────────────────────────────────────
|
|
Above everything else on purpose. All three volumes (postgres_data, media_data,
|
|
exports_data) sit on one filesystem, so running out doesn't degrade a subsystem —
|
|
it stops Postgres writing and takes the event down. And the keepsake needs room
|
|
for TWO gallery-sized archives, which is only actionable BEFORE the release: the
|
|
export preflight can say "this didn't fit", but by then the event is over and the
|
|
remedies are all much harder.
|
|
|
|
Only the admin dashboard had any storage visibility at all, and a host is often
|
|
not the admin. `disk_low` fails closed to "not low" on an unreadable mount, so
|
|
this cannot cry wolf. -->
|
|
{#if event.disk_low && event.disk_free_bytes !== null}
|
|
<div
|
|
class="rounded-xl border border-red-300 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30"
|
|
data-testid="low-disk-warning"
|
|
>
|
|
<h2 class="font-semibold text-red-900 dark:text-red-200">Speicherplatz wird knapp</h2>
|
|
<p class="mt-1 text-sm text-red-800 dark:text-red-300">
|
|
Noch <strong>{formatBytes(event.disk_free_bytes)}</strong> frei.
|
|
{#if event.keepsake_required_bytes > event.disk_free_bytes}
|
|
Für das Keepsake werden derzeit ca.
|
|
<strong>{formatBytes(event.keepsake_required_bytes)}</strong> benötigt — es kann
|
|
momentan <strong>nicht</strong> erstellt werden.
|
|
{/if}
|
|
</p>
|
|
<p class="mt-1.5 text-xs text-red-700 dark:text-red-400">
|
|
Schaffe Speicher frei oder vergrößere den Datenträger. Wenn der Datenträger vollläuft,
|
|
fällt das gesamte Event aus — nicht nur der Download.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── 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="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'}
|
|
</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.
|
|
</p>
|
|
</div>
|
|
<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
|
|
>
|
|
<div class="flex shrink-0 gap-2">
|
|
<button
|
|
onclick={() => dismissPinRequest(req)}
|
|
class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
<button
|
|
onclick={() => resetPinForRequest(req)}
|
|
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
|
|
>
|
|
PIN zurücksetzen
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
|
<div class="card overflow-hidden">
|
|
<button
|
|
onclick={() => (statsOpen = !statsOpen)}
|
|
aria-expanded={statsOpen}
|
|
class="flex w-full items-center justify-between px-5 py-4"
|
|
>
|
|
<h2 class="section-title">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"
|
|
>
|
|
<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="surface-muted p-4 text-center">
|
|
<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="surface-muted p-4 text-center">
|
|
<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="surface-muted flex flex-col items-center p-4 text-center">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold {event.uploads_locked
|
|
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
|
: 'bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300'}"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
|
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
|
|
</span>
|
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
|
</div>
|
|
<div class="surface-muted flex flex-col items-center p-4 text-center">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold {event.export_released
|
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-950/50 dark:text-purple-200'
|
|
: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
|
{event.export_released ? 'Freigegeben' : 'Noch nicht'}
|
|
</span>
|
|
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">Galerie</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
|
|
<div class="card overflow-hidden">
|
|
<button
|
|
onclick={() => (settingsOpen = !settingsOpen)}
|
|
aria-expanded={settingsOpen}
|
|
class="flex w-full items-center justify-between px-5 py-4"
|
|
>
|
|
<h2 class="section-title">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"
|
|
>
|
|
<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="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700">
|
|
<button
|
|
onclick={toggleEventLock}
|
|
class="rounded-lg border px-4 py-2 text-sm font-medium transition
|
|
{event.uploads_locked
|
|
? 'border-green-600 bg-green-600 text-white hover:bg-green-700 dark:border-green-500 dark:bg-green-500 dark:hover:bg-green-400'
|
|
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'}"
|
|
>
|
|
{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
|
|
})}
|
|
disabled={event.export_released}
|
|
class="btn btn-primary btn-sm"
|
|
>
|
|
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and
|
|
downloads 404 until they're done — surface it so a host doesn't announce
|
|
"released!" while guest downloads still fail. -->
|
|
{#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>
|
|
</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
|
|
>
|
|
</p>
|
|
{:else}
|
|
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
|
|
<!-- The reason, not just the verdict. "Erneut versuchen" is the only control here,
|
|
and for the one failure that is actually common — not enough disk — retrying
|
|
without freeing space fails identically forever. The backend already wrote a
|
|
message naming the numbers; it just wasn't reaching this screen. -->
|
|
{#if exportError}
|
|
<p class="mt-1 text-red-700/80 dark:text-red-300/80">{exportError}</p>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches above.
|
|
Those branches are driven by SSE (`export-progress` / `export-available`), and
|
|
`rebuildExport` itself makes the backend broadcast `export-progress` at 0%
|
|
immediately. If the button lived inside a branch, activating it would flip
|
|
`exportGenerating` and UNMOUNT THE BUTTON BEING PRESSED — and a worker tick
|
|
landing between mousedown and mouseup would do the same unprompted. Chromium
|
|
fires no `click` when the element dies mid-sequence, so the host would tap
|
|
"Erneut versuchen" and nothing at all would happen, on the one screen whose
|
|
entire purpose is recovering a broken keepsake. (Same class of bug as the feed
|
|
autocomplete: never let a handler destroy the node that is being clicked.)
|
|
|
|
So the button's EXISTENCE is invariant; only its label and `disabled` change. -->
|
|
<button
|
|
onclick={() => {
|
|
// Rebuilding a READY keepsake is disruptive — guests who tap Download during
|
|
// the rebuild get nothing until it finishes — so it gets a confirm. A FAILED
|
|
// keepsake has nothing to lose, so it retries immediately.
|
|
if (exportReady) {
|
|
confirmAction = {
|
|
title: 'Keepsake neu erstellen?',
|
|
message:
|
|
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
|
|
'Während der Erstellung können Gäste es nicht herunterladen.',
|
|
confirmLabel: 'Neu erstellen',
|
|
tone: 'danger',
|
|
run: rebuildExport
|
|
};
|
|
} else {
|
|
void rebuildExport();
|
|
}
|
|
}}
|
|
disabled={rebuilding || exportGenerating}
|
|
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'}"
|
|
>
|
|
{rebuilding
|
|
? 'Wird gestartet…'
|
|
: exportReady
|
|
? 'Neu erstellen'
|
|
: 'Erneut versuchen'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
|
|
<div class="card overflow-hidden">
|
|
<button
|
|
onclick={() => (usersOpen = !usersOpen)}
|
|
aria-expanded={usersOpen}
|
|
class="flex w-full items-center justify-between px-5 py-4"
|
|
>
|
|
<h2 class="section-title">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"
|
|
>
|
|
<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="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"
|
|
/>
|
|
</svg>
|
|
<input
|
|
type="search"
|
|
placeholder="Nutzer suchen…"
|
|
bind:value={userSearch}
|
|
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
|
|
/>
|
|
</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>
|
|
{:else}
|
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
{#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
|
|
>
|
|
{#if user.role === 'host'}
|
|
<span class="badge badge-primary">Host</span>
|
|
{:else if user.role === 'admin'}
|
|
<span class="badge badge-gold">Admin</span>
|
|
{/if}
|
|
{#if user.is_banned}
|
|
<span class="badge badge-danger">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
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
|
{#if user.role !== 'admin'}
|
|
{#if user.is_banned}
|
|
<!-- 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${$commentsEnabled ? ', liken und kommentieren' : ' und liken'}.`,
|
|
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
|
|
caller's own row: the backend rejects every self-action
|
|
(self-ban / self-demote / self-PIN) with a 400, so the button
|
|
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)
|
|
})}
|
|
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' && myRole === 'admin'}
|
|
<!-- 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)
|
|
})}
|
|
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 canModerate(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}
|
|
{#if canModerate(user)}
|
|
<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}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|