Files
vtuber-awards/frontend/src/views/admin/AdminLayoutView.vue
T
2026-06-26 18:09:29 +02:00

271 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import {
AlertTriangle,
BarChart3,
CalendarCog,
ClipboardList,
Film,
LayoutDashboard,
FileClock,
Menu,
Settings,
Tags,
Trophy,
UserCog,
Users,
FileText,
X,
} from '@lucide/vue'
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
import Card from '../../components/ui/Card.vue'
import { getRiskMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const route = useRoute()
const store = useAwardsStore()
const authStore = useAuthStore()
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const mobileNavOpen = ref(false)
const fullNavGroups = computed(() => [
{
label: 'Betrieb',
items: [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, permission: 'dashboard', badge: () => null },
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, permission: 'nominations', badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
],
},
{
label: 'Inhalte',
items: [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, permission: 'clips', badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, permission: 'content', badge: () => null },
],
},
{
label: 'Kontrolle',
items: [
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, permission: 'risk', badge: () => `${openRiskCount.value}` },
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, permission: 'audit', badge: () => null },
],
},
{
label: 'Auswertung',
items: [
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, permission: 'analytics', badge: () => null },
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, permission: 'settings', badge: () => null },
],
},
])
const navGroups = computed(() => {
return fullNavGroups.value
.map((group) => ({
...group,
items: group.items.filter((item) => !item.permission || authStore.hasPermission(item.permission)),
}))
.filter((group) => group.items.length > 0)
})
const currentSeason = computed(() => store.adminSeasonDetail)
function isActive(to: string) {
return route.path === to
}
// Close mobile nav on route change
watch(() => route.path, () => {
mobileNavOpen.value = false
})
async function ensureAdminWorkspace() {
if (!authStore.hydrated || !authStore.canAccessAdmin || adminWorkspaceLoading.value) return
if (adminWorkspaceLoaded.value && store.apiMode === 'api') return
adminWorkspaceLoading.value = true
try {
if (authStore.canManageAdminWorkspace) {
await store.initializeAdminWorkspace()
} else if (authStore.hasPermission('content') || authStore.hasPermission('settings')) {
await store.loadAdminContentWorkspace()
}
adminWorkspaceLoaded.value = true
} finally {
adminWorkspaceLoading.value = false
}
}
watch(
() => [authStore.hydrated, authStore.canAccessAdmin, authStore.canManageAdminWorkspace, route.path] as const,
() => {
void ensureAdminWorkspace()
},
{ immediate: true },
)
</script>
<template>
<AdminToastViewport />
<!-- Mobile top bar -->
<div class="sticky top-0 z-40 flex items-center gap-3 border-b border-violet-100 bg-white/90 px-4 py-3 backdrop-blur-sm xl:hidden">
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-200 text-violet-600 transition hover:bg-violet-50"
aria-label="Navigation öffnen"
@click="mobileNavOpen = true"
>
<Menu class="h-5 w-5" />
</button>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-violet-900">
{{ currentSeason.year ? `Admin · ${currentSeason.year}` : 'Admin' }}
</p>
<p class="truncate text-xs text-slate-500">{{ currentSeason.currentPhase || currentSeason.name || 'Kein Jahr gewählt' }}</p>
</div>
<div v-if="store.adminSeasonDetail.pendingNominations.length > 0" class="shrink-0">
<span class="grid h-6 min-w-6 place-items-center rounded-full border border-violet-200 bg-violet-50 px-2 text-[11px] font-bold text-violet-700">
{{ store.adminSeasonDetail.pendingNominations.length }}
</span>
</div>
</div>
<!-- Mobile drawer (Teleport to body) -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="mobileNavOpen" class="fixed inset-0 z-50 xl:hidden" @click.self="mobileNavOpen = false">
<!-- Backdrop -->
<div class="absolute inset-0 bg-slate-950/40 backdrop-blur-sm" @click="mobileNavOpen = false" />
<!-- Drawer panel -->
<Transition
enter-active-class="transition duration-200"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-150"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"
>
<div v-if="mobileNavOpen" class="absolute inset-y-0 left-0 flex w-72 flex-col gap-3 overflow-y-auto bg-white/98 p-3 shadow-2xl backdrop-blur-md">
<!-- Drawer header -->
<div class="flex items-center justify-between gap-3 px-1 py-1">
<div class="min-w-0">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Admin</p>
<p class="truncate text-sm font-bold text-violet-900">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || '' }}</p>
</div>
<button
type="button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-200 text-violet-600 transition hover:bg-violet-50"
aria-label="Navigation schließen"
@click="mobileNavOpen = false"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Nav groups -->
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
<p class="px-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">{{ group.label }}</p>
<RouterLink
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="group block rounded-2xl border px-3 py-2.5 transition"
:class="isActive(item.to) ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
>
<div class="flex items-center gap-3">
<div
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
:class="isActive(item.to) ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
>
<component :is="item.icon" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center justify-between gap-3">
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
<span
v-if="item.badge()"
class="grid h-6 min-w-6 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-[11px] font-semibold text-violet-700 shadow-sm"
>
{{ item.badge() }}
</span>
</div>
<p class="mt-0.5 truncate text-xs leading-5 text-slate-500">{{ item.description }}</p>
</div>
</div>
</RouterLink>
</section>
</nav>
</div>
</Transition>
</div>
</Transition>
</Teleport>
<div class="admin-panel pb-10 pt-4 xl:pt-0">
<div class="grid gap-6 xl:grid-cols-[292px_minmax(0,1fr)]">
<main class="order-1 min-w-0 xl:order-2">
<RouterView />
</main>
<!-- Desktop sidebar (hidden on mobile) -->
<aside class="order-2 hidden space-y-3 xl:order-1 xl:block xl:sticky xl:top-4 xl:h-fit">
<Card class="p-3">
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
<p class="px-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">{{ group.label }}</p>
<RouterLink
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="group block rounded-2xl border px-3 py-2.5 transition"
:class="isActive(item.to) ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
>
<div class="flex items-center gap-3">
<div
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
:class="isActive(item.to) ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
>
<component :is="item.icon" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center justify-between gap-3">
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
<span
v-if="item.badge()"
class="grid h-6 min-w-6 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-[11px] font-semibold text-violet-700 shadow-sm"
>
{{ item.badge() }}
</span>
</div>
<p class="mt-0.5 truncate text-xs leading-5 text-slate-500">{{ item.description }}</p>
</div>
</div>
</RouterLink>
</section>
</nav>
</Card>
</aside>
</div>
</div>
</template>