Files
sexy/packages/frontend/src/routes/admin/users/+page.svelte
Sebastian Krüger 648123fab5 feat: mobile-optimize admin section
- Layout: sidebar hidden on mobile, replaced with horizontal top nav strip
- Tables: overflow-x-auto + hide secondary columns (email/category/dates/
  plays/likes) on small screens; show email inline under name on mobile
- Forms: grid-cols-2 → grid-cols-1 sm:grid-cols-2 on all admin forms
- Markdown editor: Write/Preview tab toggle on mobile, side-by-side on sm+
- Padding: p-3 sm:p-6 on all admin pages for tighter mobile layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:36:52 +01:00

254 lines
8.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto, invalidateAll } from "$app/navigation";
import { page } from "$app/state";
import { SvelteURLSearchParams } from "svelte/reactivity";
import { toast } from "svelte-sonner";
import { adminUpdateUser, adminDeleteUser } from "$lib/services";
import { getAssetUrl } from "$lib/api";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "$lib/components/ui/select";
import * as Dialog from "$lib/components/ui/dialog";
import type { User } from "$lib/types";
const { data } = $props();
let searchValue = $state(data.search ?? "");
let searchTimeout: ReturnType<typeof setTimeout>;
let deleteTarget: User | null = $state(null);
let deleteOpen = $state(false);
let deleting = $state(false);
let updatingId = $state<string | null>(null);
const currentUserId = page.data.authStatus?.user?.id;
const roles = ["", "viewer", "model", "admin"] as const;
function debounceSearch(value: string) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (value) params.set("search", value);
else params.delete("search");
params.delete("offset");
goto(`?${params.toString()}`, { keepFocus: true });
}, 300);
}
function setRole(role: string) {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
if (role) params.set("role", role);
else params.delete("role");
params.delete("offset");
goto(`?${params.toString()}`);
}
async function changeUserRole(user: User, newRole: string) {
updatingId = user.id;
try {
await adminUpdateUser({ userId: user.id, role: newRole });
toast.success(`Role updated to ${newRole}`);
await invalidateAll();
} catch {
toast.error("Failed to update role");
} finally {
updatingId = null;
}
}
function confirmDelete(user: User) {
deleteTarget = user;
deleteOpen = true;
}
async function handleDelete() {
if (!deleteTarget) return;
deleting = true;
try {
await adminDeleteUser(deleteTarget.id);
toast.success("User deleted");
deleteOpen = false;
deleteTarget = null;
await invalidateAll();
} catch {
toast.error("Failed to delete user");
} finally {
deleting = false;
}
}
function formatDate(d: string | Date) {
return new Date(d).toLocaleDateString();
}
</script>
<div class="p-3 sm:p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Users</h1>
<span class="text-sm text-muted-foreground">{data.total} total</span>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-4">
<Input
placeholder="Search email or name…"
class="max-w-xs"
value={searchValue}
oninput={(e) => {
searchValue = (e.target as HTMLInputElement).value;
debounceSearch(searchValue);
}}
/>
<div class="flex gap-1">
{#each roles as role (role)}
<Button
size="sm"
variant={data.role === role || (!data.role && role === "") ? "default" : "outline"}
onclick={() => setRole(role)}
>
{role || "All"}
</Button>
{/each}
</div>
</div>
<!-- Table -->
<div class="rounded-lg border border-border/40 overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/30">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">User</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Email</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Role</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden md:table-cell">Joined</th>
<th class="px-4 py-3 text-right font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border/30">
{#each data.items as user (user.id)}
<tr class="hover:bg-muted/10 transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
{#if user.avatar}
<img
src={getAssetUrl(user.avatar, "mini")}
alt=""
class="h-8 w-8 rounded-full object-cover shrink-0"
/>
{:else}
<div
class="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-semibold text-primary shrink-0"
>
{(user.artist_name || user.email)[0].toUpperCase()}
</div>
{/if}
<div class="min-w-0">
<span class="font-medium block truncate">{user.artist_name || user.first_name || "—"}</span>
<span class="text-xs text-muted-foreground sm:hidden truncate block">{user.email}</span>
</div>
</div>
</td>
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">{user.email}</td>
<td class="px-4 py-3">
<Select
type="single"
value={user.role}
disabled={user.id === currentUserId || updatingId === user.id}
onValueChange={(v) => v && changeUserRole(user, v)}
>
<SelectTrigger class="w-24 h-7 text-xs">
{user.role}
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="model">Model</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</td>
<td class="px-4 py-3 text-muted-foreground hidden md:table-cell">{formatDate(user.date_created)}</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button size="sm" variant="ghost" href="/admin/users/{user.id}">
<span class="icon-[ri--edit-line] h-4 w-4"></span>
</Button>
<Button
size="sm"
variant="ghost"
class="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={user.id === currentUserId}
onclick={() => confirmDelete(user)}
>
<span class="icon-[ri--delete-bin-line] h-4 w-4"></span>
</Button>
</div>
</td>
</tr>
{/each}
{#if data.items.length === 0}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-muted-foreground">No users found</td>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.total > data.limit}
<div class="flex items-center justify-between mt-4">
<span class="text-sm text-muted-foreground">
Showing {data.offset + 1}{Math.min(data.offset + data.limit, data.total)} of {data.total}
</span>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={data.offset === 0}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(Math.max(0, data.offset - data.limit)));
goto(`?${params.toString()}`);
}}
>
Previous
</Button>
<Button
size="sm"
variant="outline"
disabled={data.offset + data.limit >= data.total}
onclick={() => {
const params = new SvelteURLSearchParams(page.url.searchParams.toString());
params.set("offset", String(data.offset + data.limit));
goto(`?${params.toString()}`);
}}
>
Next
</Button>
</div>
</div>
{/if}
</div>
<!-- Delete confirmation dialog -->
<Dialog.Root bind:open={deleteOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Delete user</Dialog.Title>
<Dialog.Description>
Are you sure you want to permanently delete <strong
>{deleteTarget?.artist_name || deleteTarget?.email}</strong
>? This cannot be undone.
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (deleteOpen = false)}>Cancel</Button>
<Button variant="destructive" disabled={deleting} onclick={handleDelete}>
{deleting ? "Deleting…" : "Delete"}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>