271 lines
12 KiB
Vue
271 lines
12 KiB
Vue
<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>
|