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

244 lines
7.9 KiB
Vue

<template>
<div>
<div class="flex items-start justify-between mb-8">
<div>
<h1 class="text-xl font-semibold text-white mb-1">Statistiken</h1>
<p class="text-sm text-gray-500">Aggregierte, anonyme Nutzungsmetriken. Auto-refresh alle 60s.</p>
</div>
<div class="flex items-center gap-2">
<span v-if="lastUpdated" class="text-xs text-gray-600">
Aktualisiert {{ lastUpdatedLabel }}
</span>
<UButton
icon="heroicons:arrow-path"
size="xs"
color="neutral"
variant="ghost"
:loading="status === 'pending'"
@click="refresh()"
/>
</div>
</div>
<!-- Loading-skeleton beim ersten Load -->
<div v-if="status === 'pending' && !data" class="grid grid-cols-2 gap-4 lg:grid-cols-4 mb-8">
<div
v-for="n in 6"
:key="n"
class="rounded-lg border border-gray-800 bg-gray-900 p-4"
>
<USkeleton class="h-4 w-24 mb-3" />
<USkeleton class="h-8 w-16" />
</div>
</div>
<!-- Error / Empty-state -->
<div
v-else-if="error || !data"
class="rounded-lg border border-dashed border-red-900 bg-gray-900 p-12 text-center"
>
<UIcon name="heroicons:exclamation-triangle" class="h-8 w-8 text-red-500 mx-auto mb-3" />
<p class="text-sm text-red-400 mb-1">Stats konnten nicht geladen werden</p>
<p class="text-xs text-gray-500">{{ error?.statusMessage || error?.message || 'Backend nicht erreichbar' }}</p>
<UButton
class="mt-4"
size="xs"
color="neutral"
variant="outline"
@click="refresh()"
>
Erneut versuchen
</UButton>
</div>
<!-- Stat-cards-grid -->
<div v-else>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4 mb-8">
<div
v-for="card in cards"
:key="card.label"
class="rounded-lg border border-gray-800 bg-gray-900 p-4"
:class="[card.linkTo ? 'hover:border-gray-700 cursor-pointer transition-colors' : '']"
@click="card.linkTo ? navigateTo(card.linkTo) : undefined"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<UIcon :name="card.icon" class="h-4 w-4 text-gray-500" />
<span class="text-xs text-gray-500">{{ card.label }}</span>
</div>
<UIcon
v-if="card.linkTo"
name="heroicons:arrow-top-right-on-square"
class="h-3 w-3 text-gray-700"
/>
</div>
<p class="text-2xl font-bold text-white">{{ formatNumber(card.value) }}</p>
<div v-if="card.delta !== undefined" class="mt-2 flex items-center gap-1">
<UIcon
:name="card.delta > 0 ? 'heroicons:arrow-trending-up' : card.delta < 0 ? 'heroicons:arrow-trending-down' : 'heroicons:minus'"
class="h-3 w-3"
:class="card.delta > 0 ? 'text-emerald-500' : card.delta < 0 ? 'text-red-500' : 'text-gray-600'"
/>
<span
class="text-xs"
:class="card.delta > 0 ? 'text-emerald-500' : card.delta < 0 ? 'text-red-500' : 'text-gray-600'"
>
{{ card.delta > 0 ? '+' : '' }}{{ card.delta }}
</span>
<span class="text-xs text-gray-600">{{ card.deltaLabel }}</span>
</div>
<div v-else-if="card.sublabel" class="mt-2">
<span class="text-xs text-gray-600">{{ card.sublabel }}</span>
</div>
</div>
</div>
<!-- Domain-Approval-Progress-Section -->
<div class="rounded-lg border border-gray-800 bg-gray-900 p-6 mb-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-medium text-gray-400">Domain-Genehmigungs-Quote</h2>
<span class="text-xs text-gray-600">
{{ data.domains.approved }} / {{ data.domains.approved + data.domains.pending }}
</span>
</div>
<UProgress
:model-value="approvalRatio"
:max="100"
color="primary"
size="sm"
/>
<p class="text-xs text-gray-500 mt-2">
{{ data.domains.pending }} Domain(s) warten auf Review.
<NuxtLink to="/domains" class="text-primary-400 hover:text-primary-300">
Zur Approval-Queue
</NuxtLink>
</p>
</div>
<!-- Lyra-Activity -->
<div class="rounded-lg border border-gray-800 bg-gray-900 p-6">
<div class="flex items-center gap-2 mb-2">
<UIcon name="heroicons:sparkles" class="h-4 w-4 text-purple-400" />
<h2 class="text-sm font-medium text-gray-400">Lyra-Bot-Aktivitaet (30d)</h2>
</div>
<p class="text-2xl font-bold text-white">{{ formatNumber(data.lyra.postsLast30d) }}</p>
<p class="text-xs text-gray-500 mt-1">Community-Posts in den letzten 30 Tagen</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AdminStats } from "~/server/api/stats.get"
definePageMeta({ middleware: "admin-auth" })
const { data, error, status, refresh } = await useFetch<AdminStats>("/api/stats", {
// Stats kommen aggregiert vom Backend -- kein client-side caching, immer frisch.
key: "admin-stats",
default: () => null as unknown as AdminStats,
})
// Auto-refresh alle 60s.
const lastUpdated = ref<Date | null>(data.value ? new Date() : null)
const now = ref(new Date())
let refreshInterval: ReturnType<typeof setInterval> | null = null
let clockInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refreshInterval = setInterval(async () => {
await refresh()
lastUpdated.value = new Date()
}, 60_000)
// Clock-tick fuer "vor X sek" label.
clockInterval = setInterval(() => { now.value = new Date() }, 5_000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (clockInterval) clearInterval(clockInterval)
})
watch(data, (val) => {
if (val) lastUpdated.value = new Date()
})
const lastUpdatedLabel = computed(() => {
if (!lastUpdated.value) return ""
const seconds = Math.floor((now.value.getTime() - lastUpdated.value.getTime()) / 1000)
if (seconds < 5) return "gerade eben"
if (seconds < 60) return `vor ${seconds}s`
const minutes = Math.floor(seconds / 60)
return `vor ${minutes}m`
})
interface StatCard {
label: string
value: number
icon: string
delta?: number
deltaLabel?: string
sublabel?: string
linkTo?: string
}
const cards = computed<StatCard[]>(() => {
if (!data.value) return []
const d = data.value
return [
{
label: "Total Users",
value: d.users.total,
icon: "heroicons:users",
delta: d.users.newThisWeek,
deltaLabel: "neue diese Woche",
},
{
label: "Total Posts",
value: d.posts.total,
icon: "heroicons:chat-bubble-left-right",
delta: d.posts.newThisWeek,
deltaLabel: "neue diese Woche",
},
{
label: "Domains pending",
value: d.domains.pending,
icon: "heroicons:clock",
sublabel: "warten auf Review",
linkTo: "/domains",
},
{
label: "Domains approved",
value: d.domains.approved,
icon: "heroicons:check-badge",
sublabel: "genehmigt",
},
{
label: "Feedback pending",
value: d.feedback.pending,
icon: "heroicons:inbox",
sublabel: `${d.feedback.total} insgesamt`,
},
{
label: "Lyra-Posts (30d)",
value: d.lyra.postsLast30d,
icon: "heroicons:sparkles",
sublabel: "Bot-Aktivitaet",
},
]
})
const approvalRatio = computed(() => {
if (!data.value) return 0
const total = data.value.domains.approved + data.value.domains.pending
if (total === 0) return 0
return Math.round((data.value.domains.approved / total) * 100)
})
function formatNumber(n: number): string {
if (n === undefined || n === null) return "-"
return new Intl.NumberFormat("de-DE").format(n)
}
</script>