- Install 8 shadcn components: select, skeleton, checkbox, switch, avatar, dropdown-menu, tooltip, textarea - Replace raw <select> elements with Select component in members page - Replace raw checkbox with Checkbox component in admin user details - Add Skeleton loading states to admin pages (users list, orgs list, user details) for better UX Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
245 lines
8.0 KiB
Svelte
245 lines
8.0 KiB
Svelte
<script lang="ts">
|
|
import { AlertCircle, Building, Eye, Plus, Trash2 } from "@lucide/svelte";
|
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
|
import { toast } from "svelte-sonner";
|
|
import { api } from "$lib/api/client.js";
|
|
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
|
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
|
|
import { Button } from "$lib/components/ui/button/index.js";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "$lib/components/ui/card/index.js";
|
|
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "$lib/components/ui/table/index.js";
|
|
import { formatDate } from "$lib/utils/format-date.js";
|
|
|
|
/**
|
|
* Admin Organizations list page
|
|
*/
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch all orgs
|
|
const orgsQuery = createQuery(() => ({
|
|
queryKey: ["admin", "orgs"],
|
|
queryFn: () => api.admin.orgs.list(),
|
|
}));
|
|
|
|
// Confirmation dialog state
|
|
let confirmDialogOpen = $state(false);
|
|
let confirmDialogTitle = $state("");
|
|
let confirmDialogDescription = $state("");
|
|
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
|
let isConfirmLoading = $state(false);
|
|
|
|
/**
|
|
* Handle delete org action
|
|
*/
|
|
function handleDelete(slug: string, displayName: string) {
|
|
confirmDialogTitle = "Delete Organization";
|
|
confirmDialogDescription = `Are you sure you want to delete "${displayName}" (${slug})? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
|
confirmAction = async () => {
|
|
try {
|
|
await api.admin.orgs.delete({ slug });
|
|
toast.success("Organization deleted");
|
|
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
|
|
} catch (e) {
|
|
toast.error(
|
|
e instanceof Error ? e.message : "Failed to delete organization",
|
|
);
|
|
}
|
|
};
|
|
confirmDialogOpen = true;
|
|
}
|
|
|
|
/**
|
|
* Execute confirm action
|
|
*/
|
|
async function executeConfirmAction() {
|
|
isConfirmLoading = true;
|
|
try {
|
|
await confirmAction();
|
|
confirmDialogOpen = false;
|
|
} finally {
|
|
isConfirmLoading = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Organizations | Admin | Publisher Dashboard</title>
|
|
</svelte:head>
|
|
|
|
<DashboardLayout title="Organizations">
|
|
<div class="space-y-6">
|
|
{#if orgsQuery.isPending}
|
|
<!-- Loading skeleton -->
|
|
<div class="flex items-center justify-between">
|
|
<Skeleton class="h-7 w-40" />
|
|
<Skeleton class="h-9 w-40" />
|
|
</div>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2 text-base">
|
|
<Building class="h-4 w-4" />
|
|
<Skeleton class="h-5 w-32" />
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Slug</TableHead>
|
|
<TableHead>Display Name</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
<TableHead class="w-[120px]">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{#each Array(5) as _}
|
|
<TableRow>
|
|
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
|
|
<TableCell><Skeleton class="h-4 w-32" /></TableCell>
|
|
<TableCell><Skeleton class="h-4 w-24" /></TableCell>
|
|
<TableCell>
|
|
<div class="flex items-center gap-1">
|
|
<Skeleton class="h-8 w-8" />
|
|
<Skeleton class="h-8 w-8" />
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{/each}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
{:else if orgsQuery.error}
|
|
<!-- Error state -->
|
|
<div class="flex flex-col items-center justify-center py-16">
|
|
<AlertCircle class="h-8 w-8 text-destructive" />
|
|
<p class="mt-4 text-sm text-destructive">
|
|
{orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"}
|
|
</p>
|
|
</div>
|
|
{:else if orgsQuery.data}
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">
|
|
Organizations ({orgsQuery.data.length})
|
|
</h2>
|
|
<Button href="/admin/orgs/new">
|
|
<Plus class="mr-2 h-4 w-4" />
|
|
New Organization
|
|
</Button>
|
|
</div>
|
|
|
|
{#if orgsQuery.data.length === 0}
|
|
<!-- Empty state -->
|
|
<Card class="border-dashed">
|
|
<CardContent class="flex flex-col items-center justify-center py-16">
|
|
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
|
<Building class="h-8 w-8 text-muted-foreground" />
|
|
</div>
|
|
<h3 class="mt-4 text-lg font-semibold">No organizations yet</h3>
|
|
<p class="mt-2 text-center text-sm text-muted-foreground">
|
|
Create your first organization to get started.
|
|
</p>
|
|
<Button href="/admin/orgs/new" class="mt-4">
|
|
<Plus class="mr-2 h-4 w-4" />
|
|
New Organization
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
{:else}
|
|
<!-- Organizations table -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2 text-base">
|
|
<Building class="h-4 w-4" />
|
|
All Organizations
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Slug</TableHead>
|
|
<TableHead>Display Name</TableHead>
|
|
<TableHead>Created At</TableHead>
|
|
<TableHead class="w-[120px]">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{#each orgsQuery.data as org (org.id)}
|
|
<TableRow>
|
|
<TableCell class="font-mono text-sm">{org.slug}</TableCell>
|
|
<TableCell class="font-medium">{org.displayName}</TableCell>
|
|
<TableCell class="text-muted-foreground">
|
|
{formatDate(org.createdAt)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div class="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
href="/dashboard/{org.slug}"
|
|
title="View organization"
|
|
>
|
|
<Eye class="h-4 w-4" />
|
|
<span class="sr-only">View</span>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="text-destructive hover:text-destructive"
|
|
onclick={() => handleDelete(org.slug, org.displayName)}
|
|
title="Delete organization"
|
|
>
|
|
<Trash2 class="h-4 w-4" />
|
|
<span class="sr-only">Delete</span>
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{/each}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
|
|
<!-- Back link -->
|
|
<div class="pt-4">
|
|
<a
|
|
href="/admin"
|
|
class="text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
← Back to admin dashboard
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</DashboardLayout>
|
|
|
|
<!-- Confirmation dialog -->
|
|
<ConfirmDialog
|
|
bind:open={confirmDialogOpen}
|
|
title={confirmDialogTitle}
|
|
description={confirmDialogDescription}
|
|
variant="destructive"
|
|
confirmLabel="Delete"
|
|
loading={isConfirmLoading}
|
|
onconfirm={executeConfirmAction}
|
|
oncancel={() => confirmDialogOpen = false}
|
|
/>
|