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>
244 lines
7.9 KiB
Vue
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>
|