feat: mobile-first UI redesign (v0.15.0)
- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages - Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer - Upload page redesigned as full-screen composer with thumbnail strip, textarea, quick-tag chips, sticky submit button; bottom nav suppressed while composing - Slim upload progress bar above bottom nav driven by queue state - Feed: list/grid view toggle; list = chronological full-width FeedListCard; grid = 3-col with search bar, autocomplete from loaded posts, filter chips - Account page: role-gated dashboard links (Host / Admin); Konto section with leave-confirm bottom sheet; no more per-page header nav icons - Host dashboard: back arrow, collapsible sections, 2-col stats, user search - Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer), stacked config inputs with sticky save, new Nutzer tab - BottomNav hidden on unauthenticated pages via isAuthenticated store - FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB - Concept docs added to docs/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,17 @@
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const CONFIG_LABELS: Record<string, string> = {
|
||||
max_image_size_mb: 'Max. Bildgröße (MB)',
|
||||
max_video_size_mb: 'Max. Videogröße (MB)',
|
||||
@@ -34,14 +45,36 @@
|
||||
compression_concurrency: 'Kompressions-Worker'
|
||||
};
|
||||
|
||||
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
||||
const TAB_LABELS: Record<AdminTab, string> = { stats: 'Stats', config: 'Config', export: 'Export', users: 'Nutzer' };
|
||||
|
||||
let activeTab = $state<AdminTab>('stats');
|
||||
|
||||
let stats = $state<StatsDto | null>(null);
|
||||
let config = $state<Record<string, string>>({});
|
||||
let configDraft = $state<Record<string, string>>({});
|
||||
let exportJobs = $state<ExportJob[]>([]);
|
||||
let users = $state<UserSummary[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let toast = $state<string | null>(null);
|
||||
let exportJobsRefreshing = $state(false);
|
||||
|
||||
// Nutzer tab state
|
||||
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 banHideUploads = $state(false);
|
||||
let banSubmitting = $state(false);
|
||||
|
||||
const myRole = getRole();
|
||||
|
||||
onMount(async () => {
|
||||
const token = getToken();
|
||||
@@ -57,10 +90,11 @@
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[stats, config, exportJobs] = await Promise.all([
|
||||
[stats, config, exportJobs, users] = await Promise.all([
|
||||
api.get<StatsDto>('/admin/stats'),
|
||||
api.get<Record<string, string>>('/admin/config'),
|
||||
api.get<ExportJob[]>('/admin/export/jobs')
|
||||
api.get<ExportJob[]>('/admin/export/jobs'),
|
||||
api.get<UserSummary[]>('/host/users')
|
||||
]);
|
||||
configDraft = { ...config };
|
||||
} catch (e: unknown) {
|
||||
@@ -70,8 +104,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let exportJobsRefreshing = $state(false);
|
||||
|
||||
async function refreshExportJobs() {
|
||||
exportJobsRefreshing = true;
|
||||
try {
|
||||
@@ -89,7 +121,6 @@
|
||||
async function saveConfig() {
|
||||
saving = true;
|
||||
try {
|
||||
// Only send changed values
|
||||
const changes: Record<string, string> = {};
|
||||
for (const key of Object.keys(configDraft)) {
|
||||
if (configDraft[key] !== config[key]) {
|
||||
@@ -110,15 +141,74 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseGallery() {
|
||||
try {
|
||||
await api.post('/host/gallery/release');
|
||||
showToast('Galerie wurde freigegeben. Export wird vorbereitet…');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler.');
|
||||
}
|
||||
}
|
||||
|
||||
function openBanModal(user: UserSummary) {
|
||||
banTarget = user;
|
||||
banHideUploads = false;
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (!banTarget) return;
|
||||
banSubmitting = true;
|
||||
try {
|
||||
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
|
||||
showToast(`${banTarget.display_name} wurde gesperrt.`);
|
||||
banTarget = null;
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler.');
|
||||
} finally {
|
||||
banSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unban(user: UserSummary) {
|
||||
try {
|
||||
await api.post(`/host/users/${user.id}/unban`);
|
||||
showToast(`Sperre für ${user.display_name} aufgehoben.`);
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler.');
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteToHost(user: UserSummary) {
|
||||
try {
|
||||
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
|
||||
showToast(`${user.display_name} ist jetzt Host.`);
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler.');
|
||||
}
|
||||
}
|
||||
|
||||
async function demoteToGuest(user: UserSummary) {
|
||||
try {
|
||||
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
|
||||
showToast(`${user.display_name} ist jetzt Gast.`);
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler.');
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
function diskPct(stats: StatsDto): number {
|
||||
if (stats.disk_total_bytes === 0) return 0;
|
||||
return Math.round((stats.disk_used_bytes / stats.disk_total_bytes) * 100);
|
||||
function diskPct(s: StatsDto): number {
|
||||
if (s.disk_total_bytes === 0) return 0;
|
||||
return Math.round((s.disk_used_bytes / s.disk_total_bytes) * 100);
|
||||
}
|
||||
|
||||
function jobLabel(type: string): string {
|
||||
@@ -145,139 +235,268 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Ban modal -->
|
||||
{#if banTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 class="mb-1 text-lg font-bold text-gray-900">Benutzer sperren</h2>
|
||||
<p class="mb-4 text-sm text-gray-600">
|
||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
||||
</p>
|
||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3">
|
||||
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500" />
|
||||
<span class="text-sm text-gray-700">Uploads aus der Galerie ausblenden</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50">Abbrechen</button>
|
||||
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
|
||||
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toast -->
|
||||
{#if toast}
|
||||
<div class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
|
||||
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="min-h-screen bg-gray-50 pb-24">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/host" class="text-gray-400 hover:text-gray-600" aria-label="Host Dashboard">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/feed" class="text-sm text-gray-500 hover:text-gray-700">Galerie</a>
|
||||
</div>
|
||||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||||
<button
|
||||
onclick={() => goto('/account')}
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<h1 class="text-xl font-bold text-gray-900">Admin-Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-4 p-4">
|
||||
<!-- Inner tab bar -->
|
||||
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl min-w-max">
|
||||
{#each Object.entries(TAB_LABELS) as [tab, label]}
|
||||
<button
|
||||
onclick={() => (activeTab = tab as AdminTab)}
|
||||
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||
{activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl p-4">
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400">Laden…</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
||||
{:else}
|
||||
<!-- Stats -->
|
||||
{#if stats}
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h2 class="mb-4 font-semibold text-gray-900">Statistiken</h2>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{stats.user_count}</p>
|
||||
<p class="text-xs text-gray-500">Gäste</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{stats.upload_count}</p>
|
||||
<p class="text-xs text-gray-500">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<p class="text-2xl font-bold text-gray-900">{stats.comment_count}</p>
|
||||
<p class="text-xs text-gray-500">Kommentare</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disk usage -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Speicher</span>
|
||||
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)} ({diskPct(stats)} %)</span>
|
||||
</div>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
|
||||
style="width: {diskPct(stats)}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Config -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h2 class="mb-4 font-semibold text-gray-900">Konfiguration</h2>
|
||||
<!-- ── Stats tab ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-3">
|
||||
{#each Object.entries(CONFIG_LABELS) as [key, label]}
|
||||
<div class="flex items-center gap-3">
|
||||
<label for={key} class="w-56 shrink-0 text-sm text-gray-700">{label}</label>
|
||||
<input
|
||||
id={key}
|
||||
type="number"
|
||||
step="any"
|
||||
bind:value={configDraft[key]}
|
||||
class="w-32 rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
/>
|
||||
{#if stats}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.user_count}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.upload_count}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.comment_count}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Kommentare</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{diskPct(stats)} %</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Speicher</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Disk bar -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Speicherauslastung</span>
|
||||
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span>
|
||||
</div>
|
||||
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
|
||||
style="width: {diskPct(stats)}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={saveConfig}
|
||||
disabled={saving}
|
||||
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Export jobs -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900">Export-Jobs</h2>
|
||||
<button onclick={refreshExportJobs} disabled={exportJobsRefreshing} class="text-xs text-blue-600 hover:underline disabled:opacity-50">
|
||||
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
{#if exportJobs.length === 0}
|
||||
<p class="text-sm text-gray-400">Noch keine Export-Jobs.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each exportJobs as job}
|
||||
<div class="rounded-lg border border-gray-100 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900">{jobLabel(job.type)}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
|
||||
{statusLabel(job.status)}
|
||||
</span>
|
||||
</div>
|
||||
{#if job.status === 'running'}
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||
<span>Fortschritt</span>
|
||||
<span>{job.progress_pct} %</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-500 transition-all"
|
||||
style="width: {job.progress_pct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<p class="mt-1 text-xs text-red-600">{job.error_message}</p>
|
||||
{/if}
|
||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'config'}
|
||||
<div class="relative">
|
||||
<div class="space-y-3 rounded-xl border border-gray-200 bg-white p-5 pb-20">
|
||||
{#each Object.entries(CONFIG_LABELS) as [key, label]}
|
||||
<div>
|
||||
<label for={key} class="mb-1 block text-sm font-medium text-gray-700">{label}</label>
|
||||
<input
|
||||
id={key}
|
||||
type="number"
|
||||
step="any"
|
||||
bind:value={configDraft[key]}
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Sticky save button -->
|
||||
<div class="sticky bottom-0 border-t border-gray-100 bg-white px-5 py-3">
|
||||
<button
|
||||
onclick={saveConfig}
|
||||
disabled={saving}
|
||||
class="w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Export tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'export'}
|
||||
<div class="space-y-3">
|
||||
<!-- Gallery release -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h3 class="mb-3 font-semibold text-gray-900">Galerie</h3>
|
||||
<button
|
||||
onclick={releaseGallery}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
Galerie freigeben
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Export jobs -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-gray-900">Export-Jobs</h3>
|
||||
<button
|
||||
onclick={refreshExportJobs}
|
||||
disabled={exportJobsRefreshing}
|
||||
class="text-xs text-blue-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
{#if exportJobs.length === 0}
|
||||
<p class="text-sm text-gray-400">Noch keine Export-Jobs.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each exportJobs as job}
|
||||
<div class="rounded-lg border border-gray-100 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900">{jobLabel(job.type)}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
|
||||
{statusLabel(job.status)}
|
||||
</span>
|
||||
</div>
|
||||
{#if job.status === 'running'}
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
||||
<span>Fortschritt</span><span>{job.progress_pct} %</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {job.progress_pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<p class="mt-1 text-xs text-red-600">{job.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'users'}
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<!-- Search -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if filteredUsers.length === 0}
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each filteredUsers as user}
|
||||
<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">{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">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">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">Gesperrt</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
{#if user.role !== 'admin'}
|
||||
{#if user.is_banned}
|
||||
<button onclick={() => unban(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
||||
Entsperren
|
||||
</button>
|
||||
{:else}
|
||||
{#if user.role === 'guest'}
|
||||
<button onclick={() => promoteToHost(user)} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100">
|
||||
Host
|
||||
</button>
|
||||
{/if}
|
||||
{#if user.role === 'host' && myRole === 'admin'}
|
||||
<button onclick={() => demoteToGuest(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
||||
Degradieren
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => openBanModal(user)} class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100">
|
||||
Sperren
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user