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>
519 lines
15 KiB
Vue
519 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<div class="flex items-start justify-between mb-8">
|
|
<div>
|
|
<h1 class="text-xl font-semibold text-white mb-1">Moderation</h1>
|
|
<p class="text-sm text-gray-500">
|
|
Gemeldete Inhalte prüfen, dismiss / delete / ban-user. Audit-Log persistiert.
|
|
</p>
|
|
</div>
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="heroicons:arrow-path"
|
|
:loading="pending"
|
|
size="sm"
|
|
@click="reload()"
|
|
>
|
|
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 Moderation-Queue
|
|
</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 && items.length === 0"
|
|
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 Moderation-Queue…</p>
|
|
</div>
|
|
|
|
<!-- Empty-State -->
|
|
<div
|
|
v-else-if="items.length === 0"
|
|
class="rounded-lg border border-dashed border-gray-700 bg-gray-900 p-12 text-center"
|
|
>
|
|
<UIcon
|
|
name="heroicons:shield-check"
|
|
class="h-8 w-8 text-emerald-600 mx-auto mb-3"
|
|
/>
|
|
<p class="text-sm text-gray-300">Keine offene moderation-items</p>
|
|
<p class="text-xs text-gray-500 mt-2">
|
|
Alle gemeldeten Inhalte sind bearbeitet.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Stack-Layout: ein Card pro Item -->
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="item in items"
|
|
:key="`${item.type}:${item.id}`"
|
|
class="rounded-lg border border-gray-800 bg-gray-900 p-5"
|
|
>
|
|
<!-- Header-Row: type-badge, user-info, reportedAt -->
|
|
<div class="flex items-start justify-between gap-4 mb-3">
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<UBadge
|
|
:color="item.type === 'post' ? 'primary' : 'info'"
|
|
variant="subtle"
|
|
size="sm"
|
|
>
|
|
{{ item.type === "post" ? "Post" : "Comment" }}
|
|
</UBadge>
|
|
|
|
<UBadge
|
|
v-if="item.isDeleted"
|
|
color="error"
|
|
variant="subtle"
|
|
size="sm"
|
|
>
|
|
gelöscht
|
|
</UBadge>
|
|
|
|
<span class="text-xs text-gray-500">
|
|
<UIcon
|
|
name="heroicons:user-circle"
|
|
class="h-3.5 w-3.5 inline -mt-0.5"
|
|
/>
|
|
{{ item.author?.nickname || "—" }}
|
|
<span class="text-gray-600 ml-1">
|
|
({{ shortId(item.userId) }})
|
|
</span>
|
|
</span>
|
|
|
|
<UBadge
|
|
v-if="item.author?.plan && item.author.plan !== 'free'"
|
|
:color="item.author.plan === 'legend' ? 'warning' : 'success'"
|
|
variant="subtle"
|
|
size="xs"
|
|
>
|
|
{{ item.author.plan }}
|
|
</UBadge>
|
|
</div>
|
|
|
|
<span class="text-xs text-gray-500 shrink-0">
|
|
{{
|
|
item.reportedAt
|
|
? `gemeldet ${formatDate(item.reportedAt)}`
|
|
: `erstellt ${formatDate(item.createdAt)}`
|
|
}}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Content-Preview (max 200 chars) -->
|
|
<div
|
|
class="rounded border border-gray-800 bg-gray-950/60 px-4 py-3 mb-4"
|
|
>
|
|
<p
|
|
v-if="item.isDeleted"
|
|
class="text-sm italic text-gray-600"
|
|
>
|
|
[Inhalt bereits gelöscht — Original im Audit-Log]
|
|
</p>
|
|
<p v-else class="text-sm text-gray-200 whitespace-pre-wrap">
|
|
{{ truncate(item.content, 200) }}
|
|
</p>
|
|
<p
|
|
v-if="item.type === 'comment' && item.postId"
|
|
class="text-xs text-gray-600 mt-2"
|
|
>
|
|
zum Post {{ shortId(item.postId) }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Action-Buttons -->
|
|
<div class="flex items-center justify-end gap-2 flex-wrap">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="heroicons:check"
|
|
:loading="busy(item, 'dismiss')"
|
|
:disabled="anyBusy"
|
|
@click="onDismiss(item)"
|
|
>
|
|
Dismiss
|
|
</UButton>
|
|
<UButton
|
|
color="error"
|
|
variant="soft"
|
|
size="xs"
|
|
icon="heroicons:trash"
|
|
:disabled="anyBusy || item.isDeleted"
|
|
:loading="busy(item, 'delete')"
|
|
@click="askDelete(item)"
|
|
>
|
|
Delete Content
|
|
</UButton>
|
|
<UButton
|
|
color="error"
|
|
variant="solid"
|
|
size="xs"
|
|
icon="heroicons:no-symbol"
|
|
:disabled="anyBusy"
|
|
:loading="busy(item, 'ban')"
|
|
@click="askBan(item)"
|
|
>
|
|
Ban User
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="nextCursor" class="flex justify-center pt-4">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:loading="loadingMore"
|
|
@click="loadMore"
|
|
>
|
|
Mehr laden
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete-Confirm-Modal -->
|
|
<UModal v-model:open="deleteOpen" title="Inhalt löschen">
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<p class="text-sm text-gray-300">
|
|
{{ deleteTarget?.type === "post" ? "Post" : "Kommentar" }}
|
|
wirklich soft-löschen? Original-Inhalt bleibt im Audit-Log
|
|
erhalten.
|
|
</p>
|
|
<UTextarea
|
|
v-model="deleteReason"
|
|
placeholder="Optionale Begründung (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="anyBusy"
|
|
@click="deleteOpen = false"
|
|
>
|
|
Abbrechen
|
|
</UButton>
|
|
<UButton color="error" :loading="anyBusy" @click="confirmDelete">
|
|
Löschen
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
|
|
<!-- Ban-User-Confirm-Modal -->
|
|
<UModal v-model:open="banOpen" title="User bannen">
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<div
|
|
class="rounded border border-red-900 bg-red-950/40 px-4 py-3 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="text-xs text-red-300">
|
|
User wird auf API-Ebene gesperrt (Login bleibt unberührt).
|
|
Reuses /api/admin/users[id]-Patch-Pattern.
|
|
</div>
|
|
</div>
|
|
<p class="text-sm text-gray-300">
|
|
User
|
|
<span class="font-mono text-white">{{
|
|
banTarget?.author?.nickname || shortId(banTarget?.userId || "")
|
|
}}</span>
|
|
wegen
|
|
{{ banTarget?.type === "post" ? "diesem Post" : "diesem Kommentar" }}
|
|
bannen?
|
|
</p>
|
|
<UTextarea
|
|
v-model="banReason"
|
|
placeholder="Begründung (Pflicht intern, im Audit-Log persistiert)…"
|
|
:rows="3"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2 w-full">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
:disabled="anyBusy"
|
|
@click="banOpen = false"
|
|
>
|
|
Abbrechen
|
|
</UButton>
|
|
<UButton color="error" :loading="anyBusy" @click="confirmBan">
|
|
Bannen
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type {
|
|
ModerationItem,
|
|
ModerationQueueResponse,
|
|
} from "~/server/api/moderation/queue.get";
|
|
|
|
definePageMeta({ middleware: "admin-auth" });
|
|
|
|
const toast = useToast();
|
|
|
|
const items = ref<ModerationItem[]>([]);
|
|
const nextCursor = ref<string | null>(null);
|
|
const pending = ref(false);
|
|
const error = ref<{ statusMessage?: string; message?: string } | null>(null);
|
|
|
|
async function fetchPage(cursor: string | null = null) {
|
|
const query: Record<string, string> = {};
|
|
if (cursor) query.cursor = cursor;
|
|
return $fetch<ModerationQueueResponse>("/api/moderation/queue", { query });
|
|
}
|
|
|
|
async function reload() {
|
|
pending.value = true;
|
|
error.value = null;
|
|
try {
|
|
const res = await fetchPage(null);
|
|
items.value = res.items;
|
|
nextCursor.value = res.nextCursor;
|
|
} catch (err: any) {
|
|
error.value = {
|
|
statusMessage: err?.statusMessage,
|
|
message: err?.message,
|
|
};
|
|
} finally {
|
|
pending.value = false;
|
|
}
|
|
}
|
|
|
|
const loadingMore = ref(false);
|
|
async function loadMore() {
|
|
if (!nextCursor.value || loadingMore.value) return;
|
|
loadingMore.value = true;
|
|
try {
|
|
const res = await fetchPage(nextCursor.value);
|
|
items.value = [...items.value, ...res.items];
|
|
nextCursor.value = res.nextCursor;
|
|
} catch (err: any) {
|
|
toast.add({
|
|
title: "Laden fehlgeschlagen",
|
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
|
color: "error",
|
|
icon: "heroicons:exclamation-triangle",
|
|
});
|
|
} finally {
|
|
loadingMore.value = false;
|
|
}
|
|
}
|
|
|
|
// Initial-load — client-side only (Auth-Middleware redirected sonst SSR).
|
|
onMounted(() => {
|
|
reload();
|
|
});
|
|
|
|
// ─── 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;
|
|
}
|
|
}
|
|
|
|
function truncate(s: string, max: number): string {
|
|
if (!s) return "";
|
|
if (s.length <= max) return s;
|
|
return s.slice(0, max) + "…";
|
|
}
|
|
|
|
// ─── Action-Busy-State ───────────────────────────────────────────────────────
|
|
|
|
type ActionKind = "dismiss" | "delete" | "ban";
|
|
|
|
const busyKey = ref<string | null>(null); // "{type}:{id}:{action}"
|
|
|
|
function busy(item: ModerationItem, action: ActionKind): boolean {
|
|
return busyKey.value === `${item.type}:${item.id}:${action}`;
|
|
}
|
|
|
|
const anyBusy = computed(() => busyKey.value !== null);
|
|
|
|
// ─── Dismiss ─────────────────────────────────────────────────────────────────
|
|
|
|
async function onDismiss(item: ModerationItem) {
|
|
if (anyBusy.value) return;
|
|
busyKey.value = `${item.type}:${item.id}:dismiss`;
|
|
try {
|
|
await $fetch(`/api/moderation/${item.id}/dismiss`, {
|
|
method: "POST",
|
|
body: { type: item.type },
|
|
});
|
|
toast.add({
|
|
title: "Flag gelöscht",
|
|
description: item.type === "post" ? "Post" : "Kommentar",
|
|
color: "success",
|
|
icon: "heroicons:check-circle",
|
|
});
|
|
items.value = items.value.filter(
|
|
(i) => !(i.id === item.id && i.type === item.type),
|
|
);
|
|
} catch (err: any) {
|
|
toast.add({
|
|
title: "Dismiss fehlgeschlagen",
|
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
|
color: "error",
|
|
icon: "heroicons:exclamation-triangle",
|
|
});
|
|
} finally {
|
|
busyKey.value = null;
|
|
}
|
|
}
|
|
|
|
// ─── Delete ──────────────────────────────────────────────────────────────────
|
|
|
|
const deleteOpen = ref(false);
|
|
const deleteTarget = ref<ModerationItem | null>(null);
|
|
const deleteReason = ref("");
|
|
|
|
function askDelete(item: ModerationItem) {
|
|
if (anyBusy.value) return;
|
|
deleteTarget.value = item;
|
|
deleteReason.value = "";
|
|
deleteOpen.value = true;
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
const target = deleteTarget.value;
|
|
if (!target || anyBusy.value) return;
|
|
busyKey.value = `${target.type}:${target.id}:delete`;
|
|
try {
|
|
await $fetch(`/api/moderation/${target.id}/delete`, {
|
|
method: "POST",
|
|
body: {
|
|
type: target.type,
|
|
reason: deleteReason.value || undefined,
|
|
},
|
|
});
|
|
toast.add({
|
|
title: "Inhalt gelöscht",
|
|
description: "Original im Audit-Log persistiert",
|
|
color: "warning",
|
|
icon: "heroicons:trash",
|
|
});
|
|
deleteOpen.value = false;
|
|
deleteTarget.value = null;
|
|
deleteReason.value = "";
|
|
// Item lokal als gelöscht markieren statt aus Liste entfernen — Admin
|
|
// sieht weiter dass es bearbeitet wurde, kann anschließend dismiss-en.
|
|
items.value = items.value.map((i) =>
|
|
i.id === target.id && i.type === target.type
|
|
? { ...i, isDeleted: true, content: "" }
|
|
: i,
|
|
);
|
|
} catch (err: any) {
|
|
toast.add({
|
|
title: "Delete fehlgeschlagen",
|
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
|
color: "error",
|
|
icon: "heroicons:exclamation-triangle",
|
|
});
|
|
} finally {
|
|
busyKey.value = null;
|
|
}
|
|
}
|
|
|
|
// ─── Ban-User ────────────────────────────────────────────────────────────────
|
|
|
|
const banOpen = ref(false);
|
|
const banTarget = ref<ModerationItem | null>(null);
|
|
const banReason = ref("");
|
|
|
|
function askBan(item: ModerationItem) {
|
|
if (anyBusy.value) return;
|
|
banTarget.value = item;
|
|
banReason.value = "";
|
|
banOpen.value = true;
|
|
}
|
|
|
|
async function confirmBan() {
|
|
const target = banTarget.value;
|
|
if (!target || anyBusy.value) return;
|
|
busyKey.value = `${target.type}:${target.id}:ban`;
|
|
try {
|
|
await $fetch(`/api/moderation/${target.id}/ban-user`, {
|
|
method: "POST",
|
|
body: {
|
|
type: target.type,
|
|
reason: banReason.value || undefined,
|
|
},
|
|
});
|
|
toast.add({
|
|
title: "User gebannt",
|
|
description: target.author?.nickname || shortId(target.userId),
|
|
color: "error",
|
|
icon: "heroicons:no-symbol",
|
|
});
|
|
banOpen.value = false;
|
|
banTarget.value = null;
|
|
banReason.value = "";
|
|
// Refresh — andere Items vom selben User sind jetzt auch ban-betroffen.
|
|
await reload();
|
|
} catch (err: any) {
|
|
toast.add({
|
|
title: "Ban fehlgeschlagen",
|
|
description: err?.statusMessage ?? err?.message ?? "Unbekannter Fehler",
|
|
color: "error",
|
|
icon: "heroicons:exclamation-triangle",
|
|
});
|
|
} finally {
|
|
busyKey.value = null;
|
|
}
|
|
}
|
|
</script>
|