chahinebrini 68fe8afab2 feat(admin): Phase 2 Frontend — Domains/Stats/Users/Moderation pages + responsive layout
4 page-implementations + server-route-proxies (admin-secret stays server-only):

DOMAINS (apps/admin/pages/domains.vue):
- UTable mit pending-submissions queue
- Approve / Reject buttons per row
- Reject-confirm-modal mit optional note
- useToast + refresh nach action
- 3 server-routes: GET list + POST approve/reject

STATS (apps/admin/pages/stats.vue):
- Stat-cards: Total Users + delta-week, Total Posts + delta-week,
  Domains pending (link to /domains), Domains approved, Feedback pending,
  Lyra-Posts (30d)
- UProgress für Domain-Approval-Quote
- Auto-refresh 60s + manual refresh-button
- USkeleton während loading
- 1 server-route: GET /api/stats

USERS (apps/admin/pages/users.vue):
- UTable mit avatar+nickname/username, plan-badge, streak, status, createdAt
- Search-input + plan-filter dropdown
- Action-dropdown per row: Plan-Change / Ban-Toggle / Soft-Delete
- 3 separate UModals mit confirm-pattern
- Cursor-pagination (Mehr laden button)
- 3 server-routes: GET list, PATCH /:id, DELETE /:id

MODERATION (apps/admin/pages/moderation.vue):
- Stack-layout mit card-pro-item (statt table — content-preview braucht space)
- Type-badge (Post/Comment), Author + Plan-badge, content-preview (200 chars),
  reportedAt
- Action-buttons: Dismiss (gray), Delete Content (red soft + reason-modal),
  Ban User (red solid + warning-modal)
- Empty-state, cursor-pagination
- 4 server-routes: GET /queue, POST /:id/dismiss/delete/ban-user

Server-route pattern (apps/admin/server/api/...):
- Use useRuntimeConfig().adminSecret server-only
- Client never sees x-admin-secret
- Body/query passthrough to backend

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:47:05 +02:00

330 lines
9.3 KiB
Vue

<template>
<div>
<div class="flex items-start justify-between mb-8">
<div>
<h1 class="text-xl font-semibold text-white mb-1">Domain-Approval</h1>
<p class="text-sm text-gray-500">
Ausstehende Blocker-Domain-Anfragen genehmigen oder ablehnen.
</p>
</div>
<UButton
color="neutral"
variant="ghost"
icon="heroicons:arrow-path"
:loading="pending"
size="sm"
@click="refresh()"
>
Aktualisieren
</UButton>
</div>
<!-- Error-State -->
<div
v-if="error"
class="rounded-lg border border-red-900 bg-red-950/40 p-4 mb-4 flex items-start gap-3"
>
<UIcon
name="heroicons:exclamation-triangle"
class="h-5 w-5 text-red-400 shrink-0 mt-0.5"
/>
<div class="flex-1">
<p class="text-sm font-medium text-red-300">
Fehler beim Laden der Anfragen
</p>
<p class="text-xs text-red-400/80 mt-1">
{{ error.statusMessage || error.message || "Unbekannter Fehler" }}
</p>
</div>
</div>
<!-- Loading-State (initial) -->
<div
v-else-if="pending && !submissions"
class="rounded-lg border border-gray-800 bg-gray-900 p-12 text-center"
>
<UIcon
name="heroicons:arrow-path"
class="h-6 w-6 text-gray-600 mx-auto mb-3 animate-spin"
/>
<p class="text-sm text-gray-500">Lade Domain-Anfragen</p>
</div>
<!-- Empty-State -->
<div
v-else-if="!submissions || submissions.length === 0"
class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center"
>
<UIcon
name="heroicons:inbox"
class="h-8 w-8 text-gray-600 mx-auto mb-3"
/>
<p class="text-sm text-gray-500">Keine pending requests</p>
<p class="text-xs text-gray-600 mt-2">
Alle Domain-Anfragen sind aktuell bearbeitet.
</p>
</div>
<!-- Tabelle -->
<div
v-else
class="rounded-lg border border-gray-800 bg-gray-900 overflow-hidden"
>
<UTable
:data="submissions"
:columns="columns"
:ui="{
th: 'text-xs uppercase tracking-wider text-gray-500 bg-gray-900/60',
td: 'text-sm text-gray-300 align-middle',
tr: 'border-b border-gray-800 last:border-b-0',
}"
>
<template #domain-cell="{ row }">
<span class="font-mono text-white">{{ row.original.domain }}</span>
<UBadge
v-if="row.original.status === 'in_review'"
color="warning"
variant="subtle"
size="xs"
class="ml-2"
>
in_review
</UBadge>
</template>
<template #requestedBy-cell="{ row }">
<span class="font-mono text-xs text-gray-400">
{{ shortId(row.original.userId) }}
</span>
</template>
<template #requestedAt-cell="{ row }">
<span class="text-gray-400">{{
formatDate(row.original.createdAt)
}}</span>
</template>
<template #votes-cell="{ row }">
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1 text-green-400">
<UIcon name="heroicons:hand-thumb-up" class="h-3.5 w-3.5" />
{{ row.original.yesVotes }}
</span>
<span class="inline-flex items-center gap-1 text-red-400">
<UIcon name="heroicons:hand-thumb-down" class="h-3.5 w-3.5" />
{{ row.original.noVotes }}
</span>
</div>
</template>
<template #actions-cell="{ row }">
<div class="flex items-center justify-end gap-2">
<UButton
color="success"
variant="soft"
size="xs"
icon="heroicons:check"
:loading="busyId === row.original.id && busyAction === 'approve'"
:disabled="!!busyId"
@click="onApprove(row.original)"
>
Approve
</UButton>
<UButton
color="error"
variant="soft"
size="xs"
icon="heroicons:x-mark"
:loading="busyId === row.original.id && busyAction === 'reject'"
:disabled="!!busyId"
@click="askReject(row.original)"
>
Reject
</UButton>
</div>
</template>
</UTable>
</div>
<!-- Reject-Confirm-Dialog -->
<UModal v-model:open="rejectOpen" title="Domain-Anfrage ablehnen">
<template #body>
<div class="space-y-4">
<p class="text-sm text-gray-300">
Anfrage für
<span class="font-mono text-white">{{
rejectTarget?.domain
}}</span>
wirklich ablehnen?
</p>
<UTextarea
v-model="rejectNote"
placeholder="Optionaler Begründungs-Hinweis (intern)…"
:rows="3"
class="w-full"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2 w-full">
<UButton
color="neutral"
variant="ghost"
:disabled="!!busyId"
@click="rejectOpen = false"
>
Abbrechen
</UButton>
<UButton
color="error"
:loading="!!busyId"
@click="confirmReject"
>
Ablehnen
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
import type { TableColumn } from "@nuxt/ui";
definePageMeta({ middleware: "admin-auth" });
interface DomainSubmission {
id: string;
domain: string;
yesVotes: number;
noVotes: number;
status: "pending" | "in_review" | "approved" | "rejected";
createdAt: string;
userId: string;
postId: string | null;
customDomain: { id: string } | null;
}
const toast = useToast();
const {
data: submissions,
pending,
error,
refresh,
} = await useFetch<DomainSubmission[]>("/api/domain-submissions", {
default: () => [],
server: false, // Auth-Guard erst client-side -> SSR-Fetch wuerde unauthenticated landen
});
const columns: TableColumn<DomainSubmission>[] = [
{ accessorKey: "domain", header: "Domain" },
{ accessorKey: "requestedBy", header: "Angefragt von" },
{ accessorKey: "requestedAt", header: "Angefragt am" },
{ accessorKey: "votes", header: "Votes" },
{ accessorKey: "actions", header: "", meta: { class: { td: "text-right" } } },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
function shortId(id: string): string {
if (!id) return "—";
return id.slice(0, 8);
}
function formatDate(iso: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// ─── Approve / Reject ─────────────────────────────────────────────────────────
const busyId = ref<string | null>(null);
const busyAction = ref<"approve" | "reject" | null>(null);
const rejectOpen = ref(false);
const rejectTarget = ref<DomainSubmission | null>(null);
const rejectNote = ref("");
async function onApprove(row: DomainSubmission) {
if (busyId.value) return;
busyId.value = row.id;
busyAction.value = "approve";
try {
await $fetch(`/api/domain-submissions/${row.id}/approve`, {
method: "POST",
body: {},
});
toast.add({
title: "Domain genehmigt",
description: row.domain,
color: "success",
icon: "heroicons:check-circle",
});
await refresh();
} catch (err: any) {
toast.add({
title: "Approve fehlgeschlagen",
description:
err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
color: "error",
icon: "heroicons:exclamation-triangle",
});
} finally {
busyId.value = null;
busyAction.value = null;
}
}
function askReject(row: DomainSubmission) {
if (busyId.value) return;
rejectTarget.value = row;
rejectNote.value = "";
rejectOpen.value = true;
}
async function confirmReject() {
const target = rejectTarget.value;
if (!target || busyId.value) return;
busyId.value = target.id;
busyAction.value = "reject";
try {
await $fetch(`/api/domain-submissions/${target.id}/reject`, {
method: "POST",
body: { note: rejectNote.value || undefined },
});
toast.add({
title: "Domain abgelehnt",
description: target.domain,
color: "warning",
icon: "heroicons:x-circle",
});
rejectOpen.value = false;
rejectTarget.value = null;
rejectNote.value = "";
await refresh();
} catch (err: any) {
toast.add({
title: "Reject fehlgeschlagen",
description:
err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
color: "error",
icon: "heroicons:exclamation-triangle",
});
} finally {
busyId.value = null;
busyAction.value = null;
}
}
</script>