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>
330 lines
9.3 KiB
Vue
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>
|