Host and admin implement the same four operations with independently written copy, and admin was stale or wrong in every case. Its release button was always enabled and always read "Galerie freigeben", so a second tap returned a 409; it showed no release state, no keepsake progress, no failure reason, no rebuild, and never refreshed after releasing. It now matches the host page. Both dashboards subscribed to SSE and never opened the connection — `onSseEvent` only registers a handler. Every subscription was inert, so the keepsake progress bar sat frozen after a release and PIN requests appeared only on a manual refresh. It happened to work when arriving straight from /feed, which connects, and /feed disconnects on destroy, so navigating to the dashboard killed it again. The unban confirm named neither of the two things a host most needs to know: unbanning also restores ALL of that guest's previously hidden photos to the gallery, diashow and export, and it retires and rebuilds a released keepsake, during which every guest's download is briefly unavailable. The ban modal warns that uploads vanish; nothing said they come back. Both now do, gated on the gallery actually being released. "Event verlassen" implied the account was being deleted, then the dialog said the guest could log back in. It calls `DELETE /session` — this device only, nothing deleted — so it is "Abmelden" now. Gallery release now states it locks uploads and is reversible; PIN reset states the guest is signed out on all devices. The keepsake download failed silently: nothing inspected the iframe result and the ticket POST always succeeded, so an over-limit tap did nothing at all. It now surfaces the (newly visible) 429 and confirms the download started. `/export` rendered "Export noch nicht verfügbar / Schau nach der Veranstaltung noch einmal vorbei" when the status request had merely FAILED — telling a guest to come back after an event that already happened. Both dashboards' error states gained a retry, which a host on a PWA with no URL bar otherwise has no way to reach. Modals were centred with no max-height, so on a short viewport the join PIN dialog clipped equally top and bottom — potentially putting "Weiter zur Galerie" off-screen at the moment a first-time guest must proceed. The ten moderation buttons were ~28px tall side by side, on the screen where a mis-tap bans the wrong guest; they are 44px now. Six German quotation marks paired the opening „ with an ASCII straight quote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1027 lines
41 KiB
Svelte
1027 lines
41 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 { connectSse, disconnectSse, 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;
|
|
|
|
// Open the stream. `onSseEvent` only REGISTERS a handler — it does not connect, and this
|
|
// page never did, so every subscription below was inert: the keepsake progress bar sat
|
|
// frozen after a release and PIN requests only appeared on a manual refresh. (It happened
|
|
// to work when arriving straight from /feed, which connects — and /feed disconnects on
|
|
// destroy, so navigating here killed it again.) Idempotent, so overlapping callers are fine.
|
|
connectSse();
|
|
|
|
// 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;
|
|
disconnectSse();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy for the unban confirm. The old one-liner mentioned only that the guest may post
|
|
* again, which is the least of it. Verified against `unban_user` (backend/src/handlers/host.rs):
|
|
* - it clears `uploads_hidden`, so every photo the ban hid comes BACK — to the gallery, the
|
|
* diashow and the export. The ban modal warns they vanish; nothing said they return, and
|
|
* that is the half that surprises a host in front of a room full of guests.
|
|
* - it runs `invalidate_and_arm`, so a released keepsake is retired and rebuilt, and every
|
|
* guest's download is unavailable while that runs.
|
|
* Comments are an event-level toggle, so the verb list branches like the ban copy does.
|
|
*/
|
|
function unbanMessage(user: UserSummary): string {
|
|
const actions = $commentsEnabled ? ', liken und kommentieren' : ' und liken';
|
|
let msg =
|
|
`${user.display_name} kann danach wieder hochladen${actions}. ` +
|
|
`Alle bisher ausgeblendeten Uploads von ${user.display_name} erscheinen wieder in Galerie, Diashow und Export.`;
|
|
if (event?.export_released) {
|
|
msg +=
|
|
' Das bereits freigegebene Keepsake wird deshalb neu erstellt — währenddessen können Gäste es kurz nicht herunterladen.';
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
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
|
|
? // The reset also revokes every session (`Session::delete_all_for_user`), so the guest
|
|
// is signed out on their phone mid-event and must re-enter name + new PIN. The old
|
|
// wording described a credential rotation and omitted the logout entirely — the
|
|
// person pressing this needs to know they are about to interrupt someone.
|
|
`Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr, und ${pinResetTarget.display_name} wird auf allen Geräten abgemeldet und muss sich mit der neuen PIN neu anmelden.`
|
|
: ''}
|
|
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“.
|
|
{#if event?.export_released}
|
|
<!-- The mirror of the unban sentence. `ban_user` runs `invalidate_and_arm` in the same
|
|
transaction, so a ban on a RELEASED event retires the current keepsake and starts a
|
|
rebuild — every guest's download 404s for the couple of minutes that takes. Only say
|
|
so when there is a released keepsake to lose; before release it is noise. -->
|
|
Da die Galerie bereits freigegeben ist, wird das Keepsake ohne diese Uploads neu erstellt — währenddessen
|
|
können Gäste es kurz nicht herunterladen.
|
|
{/if}
|
|
</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}
|
|
<!-- A retry, not just a verdict. `reload()` fails for entirely transient reasons (the
|
|
venue's wifi dropping for a second), and without a button in here the only way out
|
|
is a browser reload — which a host running this as an installed PWA has no URL bar
|
|
to reach for, and may not think of at all. -->
|
|
<div
|
|
role="alert"
|
|
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300"
|
|
>
|
|
<p>{error}</p>
|
|
<button onclick={() => void reload()} class="btn btn-secondary btn-sm mt-3">
|
|
Erneut laden
|
|
</button>
|
|
</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:
|
|
// Was: "Das kann nicht rückgängig gemacht werden." It is reversible — `open_event`
|
|
// clears `export_released_at`, and this page offers exactly that below
|
|
// ("Galerie-Freigabe zurücknehmen?"). The old wording also omitted that
|
|
// releasing LOCKS uploads in the same statement, so a host releasing early
|
|
// believed the party's uploads were shut for good and never reopened them.
|
|
'Uploads werden dabei gesperrt, und Gäste können alle Fotos herunterladen. Rückgängig machbar: „Uploads wieder öffnen“ zieht die Freigabe zurück.',
|
|
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
|
|
>
|
|
<!-- "Herunterladen" everywhere, never "Download" — the guests this app is for are
|
|
German-speaking and not necessarily technical, and the same action must not
|
|
have two names across the export page and the dashboards. -->
|
|
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
|
|
>Herunterladen</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>
|
|
<!-- Open state SCROLLS rather than relying on a pixel ceiling. `max-h-[9999px]`
|
|
with `overflow-hidden` silently swallowed everything past ~9999px: guest rows
|
|
wrap to ~90-110px on a phone, so a ~100-guest event sits right on that
|
|
boundary, and the guests past it could not be banned, PIN-reset or promoted —
|
|
with nothing on screen to say they existed (the "Teilnehmer" tile still
|
|
counted them). A bounded max-height plus scroll keeps the collapse animation
|
|
and makes the list complete at any guest count. -->
|
|
<div
|
|
class="transition-[max-height] duration-300 {usersOpen
|
|
? 'max-h-[70vh] overflow-y-auto overscroll-contain'
|
|
: 'max-h-0 overflow-hidden'}"
|
|
>
|
|
<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>
|
|
<!-- 44 px targets and a wider gap, not the ~28 px/6 px this had. Five buttons
|
|
sit shoulder to shoulder on a phone here, and a mis-tap bans the wrong
|
|
guest in front of a room — this is the screen where the fat-finger margin
|
|
has to be generous. -->
|
|
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
|
{#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: unbanMessage(user),
|
|
confirmLabel: 'Entsperren',
|
|
tone: 'default',
|
|
run: () => unban(user)
|
|
})}
|
|
class="inline-flex min-h-11 items-center 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="inline-flex min-h-11 items-center 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="inline-flex min-h-11 items-center 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="inline-flex min-h-11 items-center 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="inline-flex min-h-11 items-center 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>
|