Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
@@ -0,0 +1,162 @@
<template>
<Teleport to="body">
<div v-if="entry" class="fixed inset-0 z-50">
<button
type="button"
class="absolute inset-0 bg-slate-950/35 backdrop-blur-sm"
aria-label="Audit-Details schließen"
@click="$emit('close')"
/>
<aside class="absolute right-0 top-0 flex h-full w-full max-w-2xl flex-col overflow-hidden bg-white shadow-2xl">
<header class="border-b border-slate-100 px-6 py-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Audit Detail</p>
<h2 class="mt-2 text-2xl font-bold leading-8 text-slate-950">{{ entry.actionLabel }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ entry.summary }}</p>
</div>
<button
type="button"
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
aria-label="Schließen"
@click="$emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto px-6 py-5">
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Zeitpunkt</p>
<p class="mt-1 font-semibold text-slate-900">{{ entry.createdLabel }}</p>
<p class="text-xs text-slate-500">{{ entry.ageLabel }}</p>
</div>
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Admin</p>
<p class="mt-1 break-words font-semibold text-slate-900">{{ entry.adminTwitchUserId }}</p>
</div>
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Objekt</p>
<p class="mt-1 break-words font-semibold text-slate-900">{{ entry.entityLabel }}</p>
</div>
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Rohaktion</p>
<code class="mt-1 inline-block rounded-md bg-white px-2 py-1 text-xs font-semibold text-slate-700">{{ entry.actionType }}</code>
</div>
</div>
<RouterLink
v-if="entry.relatedLink"
:to="entry.relatedLink.to"
class="mt-4 inline-flex h-10 items-center gap-2 rounded-2xl border border-sky-200 bg-sky-50 px-4 text-sm font-semibold text-sky-700 transition hover:bg-sky-100"
@click="$emit('close')"
>
<ExternalLink class="h-4 w-4" />
{{ entry.relatedLink.label }}
</RouterLink>
<section class="mt-6 space-y-3">
<div class="flex items-center gap-2">
<ShieldCheck class="h-4 w-4 text-slate-500" />
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Request-Kontext</h3>
</div>
<dl class="grid gap-3">
<div v-for="item in entry.requestContextItems" :key="item.key" class="rounded-2xl border border-slate-100 px-4 py-3">
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
<dd class="mt-1 break-words text-sm font-semibold text-slate-800">{{ item.value }}</dd>
</div>
</dl>
</section>
<section v-if="entry.changeItems.length > 0" class="mt-6 space-y-3">
<div class="flex items-center gap-2">
<GitCompareArrows class="h-4 w-4 text-amber-600" />
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Änderungsdiff</h3>
</div>
<dl class="grid gap-3">
<div v-for="change in entry.changeItems" :key="`${change.field}-${change.to}`" class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
<dt class="flex flex-wrap items-center gap-2 text-sm font-bold text-slate-800">
{{ change.label }}
<span v-if="change.sensitive" class="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-amber-700">
sensibel
</span>
</dt>
<dd class="mt-3 grid gap-2 sm:grid-cols-2">
<span class="min-w-0 rounded-xl bg-white/80 px-3 py-2 text-sm text-slate-600">
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Vorher</span>
<span class="break-words">{{ change.from || 'leer' }}</span>
</span>
<span class="min-w-0 rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-emerald-600">Nachher</span>
<span class="break-words">{{ change.to || 'leer' }}</span>
</span>
</dd>
</div>
</dl>
</section>
<section class="mt-6 space-y-3">
<div class="flex items-center gap-2">
<ListTree class="h-4 w-4 text-slate-500" />
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Metadaten</h3>
</div>
<dl v-if="entry.metadataItems.length > 0" class="grid gap-3 sm:grid-cols-2">
<div v-for="item in entry.metadataItems" :key="item.key" class="min-w-0 rounded-2xl border border-slate-100 px-4 py-3">
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
</div>
</dl>
<p v-else class="rounded-2xl border border-dashed border-slate-200 px-4 py-5 text-sm text-slate-500">
Keine Metadaten erfasst.
</p>
</section>
<section class="mt-6 space-y-3">
<div class="flex items-center gap-2">
<Braces class="h-4 w-4 text-slate-500" />
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Rohdaten</h3>
</div>
<pre class="max-h-72 overflow-auto rounded-2xl border border-slate-200 bg-slate-950 p-4 text-xs leading-5 text-slate-100">{{ entry.rawMetadataJson }}</pre>
</section>
</div>
</aside>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { Braces, ExternalLink, GitCompareArrows, ListTree, ShieldCheck, X } from '@lucide/vue'
import type { AuditLogRow } from './useAdminAuditManager'
const props = defineProps<{
entry: AuditLogRow | null
}>()
const emit = defineEmits<{
close: []
}>()
function closeOnEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && props.entry) emit('close')
}
watch(
() => props.entry,
(entry) => {
if (typeof document !== 'undefined') {
document.body.style.overflow = entry ? 'hidden' : ''
}
},
)
onMounted(() => window.addEventListener('keydown', closeOnEscape))
onBeforeUnmount(() => {
window.removeEventListener('keydown', closeOnEscape)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
@@ -0,0 +1,126 @@
<template>
<Card class="p-5">
<div class="space-y-5">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Überblick</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Schwerpunkte</h2>
</div>
<div class="grid gap-2">
<div v-for="card in focusCards" :key="card.label" class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ card.label }}</p>
<strong class="mt-1 block truncate text-base text-slate-950">{{ card.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ card.note }}</p>
</div>
</div>
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Admins</p>
<button
type="button"
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
@click="$emit('update:selectedAdmin', 'all')"
>
Alle
</button>
</div>
<div class="max-h-48 space-y-2 overflow-y-auto pr-1">
<button
v-for="item in adminCounts"
:key="item.key"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
:class="selectedAdmin === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
@click="$emit('update:selectedAdmin', item.key)"
>
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
</button>
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
Noch keine Admin-Aktivität.
</p>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Aktionen</p>
<button
type="button"
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
@click="$emit('update:selectedAction', 'all')"
>
Alle
</button>
</div>
<div class="max-h-56 space-y-2 overflow-y-auto pr-1">
<button
v-for="item in actionCounts"
:key="item.key"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
:class="selectedAction === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
@click="$emit('update:selectedAction', item.key)"
>
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
</button>
<p v-if="actionCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
Noch keine Aktionstypen.
</p>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Objekte</p>
<button
type="button"
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
@click="$emit('update:selectedEntity', 'all')"
>
Alle
</button>
</div>
<div class="max-h-48 space-y-2 overflow-y-auto pr-1">
<button
v-for="item in entityCounts"
:key="item.key"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
:class="selectedEntity === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
@click="$emit('update:selectedEntity', item.key)"
>
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
</button>
<p v-if="entityCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
Noch keine Objekttypen.
</p>
</div>
</section>
</div>
</Card>
</template>
<script setup lang="ts">
import type { AuditCountItem, AuditFocusCard } from './useAdminAuditManager'
import Card from '../ui/Card.vue'
defineProps<{
focusCards: AuditFocusCard[]
adminCounts: AuditCountItem[]
actionCounts: AuditCountItem[]
entityCounts: AuditCountItem[]
selectedAdmin: string
selectedAction: string
selectedEntity: string
}>()
defineEmits<{
'update:selectedAdmin': [value: string]
'update:selectedAction': [value: string]
'update:selectedEntity': [value: string]
}>()
</script>
@@ -0,0 +1,142 @@
<template>
<Card class="overflow-hidden">
<div class="border-b border-slate-100 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Audit Log</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Letzte Aktionen</h2>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-sky-100 text-sky-700">
<FileClock class="h-5 w-5" />
</div>
</div>
</div>
<div class="max-h-[760px] divide-y divide-slate-100 overflow-y-auto" :aria-busy="loading">
<article v-for="entry in entries" :key="entry.id" class="grid gap-4 px-5 py-4 sm:grid-cols-[16px_minmax(0,1fr)]">
<span class="mt-2 h-3 w-3 rounded-full ring-4 ring-white" :class="entry.dotClass" />
<div class="min-w-0">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="entry.actionToneClass">
{{ entry.actionLabel }}
</span>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{{ entry.actionGroup }}</span>
</div>
<h3 class="mt-3 text-base font-bold leading-6 text-slate-950">{{ entry.summary }}</h3>
</div>
<div class="flex shrink-0 items-start gap-3">
<time class="text-right text-sm text-slate-500" :datetime="entry.createdAt">
{{ entry.createdLabel }}
<span class="block text-xs text-slate-400">{{ entry.ageLabel }}</span>
</time>
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-slate-700 shadow-none hover:bg-slate-50"
@click="$emit('openDetail', entry)"
>
<Eye class="h-4 w-4" />
Details
</Button>
</div>
</div>
<div class="mt-3 grid gap-2 text-sm text-slate-500 md:grid-cols-3">
<p>
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Admin</span>
<span class="font-semibold text-slate-700">{{ entry.adminTwitchUserId }}</span>
</p>
<p>
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Objekt</span>
<span class="font-semibold text-slate-700">{{ entry.entityLabel }}</span>
</p>
<p>
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Rohaktion</span>
<code class="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs font-semibold text-slate-700">{{ entry.actionType }}</code>
</p>
</div>
<div v-if="entry.changeItems.length > 0" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
<p class="text-sm font-semibold text-amber-800">Änderungsdiff</p>
<dl class="mt-3 grid gap-2">
<div
v-for="change in entry.changeItems"
:key="`${change.field}-${change.to}`"
class="grid gap-2 rounded-xl border border-white/80 bg-white/80 px-3 py-2 text-sm lg:grid-cols-[150px_minmax(0,1fr)]"
>
<dt class="font-bold text-slate-700">
{{ change.label }}
<span v-if="change.sensitive" class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-amber-700">
sensibel
</span>
</dt>
<dd class="grid gap-2 sm:grid-cols-2">
<span class="min-w-0 rounded-lg bg-slate-50 px-2 py-1 text-slate-600">
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Vorher</span>
<span class="break-words">{{ change.from || 'leer' }}</span>
</span>
<span class="min-w-0 rounded-lg bg-emerald-50 px-2 py-1 text-emerald-800">
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-emerald-600">Nachher</span>
<span class="break-words">{{ change.to || 'leer' }}</span>
</span>
</dd>
</div>
</dl>
</div>
<details v-if="entry.metadataItems.length > 0" class="mt-3 rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<summary class="cursor-pointer text-sm font-semibold text-sky-700">Metadaten anzeigen</summary>
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
<div v-for="item in entry.metadataItems" :key="item.key" class="min-w-0">
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
<dd class="mt-0.5 break-words text-slate-700">{{ item.value }}</dd>
</div>
</dl>
</details>
</div>
</article>
<p v-if="entries.length === 0" class="px-5 py-14 text-center text-sm text-slate-500">
{{ emptyText }}
</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4">
<p class="text-sm font-semibold text-slate-500">{{ pageSummaryLabel }}</p>
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-700 shadow-none hover:bg-slate-50"
:disabled="!hasMore || loadingMore || loading"
@click="$emit('loadMore')"
>
<ChevronDown class="h-4 w-4" :class="{ 'animate-bounce': loadingMore }" />
{{ hasMore ? (loadingMore ? 'Lädt ...' : 'Mehr laden') : 'Alles geladen' }}
</Button>
</div>
</Card>
</template>
<script setup lang="ts">
import { ChevronDown, Eye, FileClock } from '@lucide/vue'
import type { AuditLogRow } from './useAdminAuditManager'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
entries: AuditLogRow[]
loading: boolean
loadingMore: boolean
hasMore: boolean
pageSummaryLabel: string
emptyText: string
}>()
defineEmits<{
openDetail: [entry: AuditLogRow]
loadMore: []
}>()
</script>
@@ -0,0 +1,142 @@
<template>
<Card class="p-5">
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_auto]">
<div class="grid gap-3 lg:grid-cols-[minmax(260px,1.5fr)_180px_150px_150px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-sky-500" />
<input
:value="query"
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
placeholder="Admin, Aktion, Objekt, Summary oder Metadaten suchen"
type="search"
@input="$emit('update:query', ($event.target as HTMLInputElement).value)"
/>
</label>
<NativeSelect
:model-value="entityFilter"
:options="entityOptions"
@update:model-value="$emit('update:entityFilter', String($event ?? 'all'))"
/>
<label class="relative block">
<CalendarDays class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
:value="fromDate"
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
type="date"
@input="$emit('update:fromDate', ($event.target as HTMLInputElement).value)"
/>
</label>
<label class="relative block">
<CalendarDays class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
:value="toDate"
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
type="date"
@input="$emit('update:toDate', ($event.target as HTMLInputElement).value)"
/>
</label>
</div>
<div class="flex flex-wrap items-start gap-2 xl:justify-end">
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-sky-200 bg-sky-50 px-4 text-sky-700 shadow-none hover:bg-sky-100"
:disabled="loading"
@click="$emit('refresh')"
>
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
{{ loading ? 'Lädt ...' : 'Aktualisieren' }}
</Button>
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-700 shadow-none hover:bg-slate-50"
:disabled="!hasRows"
@click="$emit('export')"
>
<Download class="h-4 w-4" />
CSV
</Button>
<Button
v-if="activeFilterCount > 0"
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-600 shadow-none hover:bg-slate-50"
@click="$emit('clear')"
>
<X class="h-4 w-4" />
Filter
</Button>
</div>
</div>
<div class="mt-4 grid gap-4 2xl:grid-cols-[minmax(0,1fr)_420px]">
<div class="flex flex-wrap gap-2">
<button
v-for="preset in filterPresets"
:key="preset.key"
type="button"
class="min-h-12 rounded-2xl border px-4 py-2 text-left text-sm transition"
:class="appliedPresetKey === preset.key ? 'border-sky-300 bg-sky-50 text-sky-800' : 'border-slate-200 bg-white text-slate-700 hover:border-sky-200 hover:bg-sky-50'"
@click="$emit('applyPreset', preset.key)"
>
<span class="block font-bold">{{ preset.label }}</span>
<span class="block max-w-64 truncate text-xs opacity-75">{{ preset.description }}</span>
</button>
</div>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div v-for="stat in stats" :key="stat.label" class="rounded-2xl border border-slate-100 bg-slate-50 px-3 py-2">
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg leading-6 text-slate-950">{{ stat.value }}</strong>
<p class="truncate text-[11px] text-slate-400">{{ stat.note }}</p>
</div>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs font-semibold text-slate-400">
<span>Geladen: {{ lastLoadedLabel }} · Quelle: /api/admin/audit-entries</span>
<span v-if="exportMessage" class="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-emerald-700">{{ exportMessage }}</span>
</div>
</Card>
</template>
<script setup lang="ts">
import { CalendarDays, Download, RefreshCw, Search, X } from '@lucide/vue'
import type { AuditEntityOption, AuditFilterPreset, AuditStat } from './useAdminAuditManager'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
query: string
entityFilter: string
entityOptions: AuditEntityOption[]
fromDate: string
toDate: string
stats: AuditStat[]
filterPresets: AuditFilterPreset[]
appliedPresetKey: string
exportMessage: string
lastLoadedLabel: string
loading: boolean
hasRows: boolean
activeFilterCount: number
}>()
defineEmits<{
'update:query': [value: string]
'update:entityFilter': [value: string]
'update:fromDate': [value: string]
'update:toDate': [value: string]
applyPreset: [value: string]
refresh: []
export: []
clear: []
}>()
</script>
@@ -0,0 +1,114 @@
<template>
<Card class="flex h-[520px] flex-col overflow-hidden xl:h-[684px]">
<div class="flex items-center justify-between gap-3 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] px-5 py-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahre</p>
<p class="mt-1 text-sm text-slate-500">{{ seasons.length }} Jahre im Setup</p>
</div>
<Button variant="ghost" size="sm" class="gap-2 border border-violet-100 text-violet-700 hover:bg-violet-50" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Neu
</Button>
</div>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<template v-if="selectedSeason">
<div class="rounded-[24px] border border-violet-100 bg-white p-5">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Ausgewählt</p>
<strong class="mt-2 block text-5xl leading-none text-slate-950">{{ selectedSeason.year }}</strong>
</div>
<span class="shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-bold" :class="selectedSeason.isCurrent ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
{{ selectedSeason.isCurrent ? 'Public' : 'Intern' }}
</span>
</div>
<p class="mt-5 line-clamp-2 text-lg font-bold leading-6 text-slate-900">{{ selectedSeason.name }}</p>
<div class="mt-5 grid gap-3 border-t border-violet-100 pt-4">
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Phase</span>
<strong class="truncate text-sm text-violet-800">{{ selectedSeason.currentPhase }}</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kategorien</span>
<strong class="text-sm text-slate-900">{{ selectedSeason.categoryCount }}</strong>
</div>
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Sichtbarkeit</span>
<strong class="text-sm text-slate-900">{{ selectedSeason.isCurrent ? 'Public aktiv' : 'Intern' }}</strong>
</div>
</div>
<div class="mt-4 flex items-center justify-end text-xs text-slate-500">
<span v-if="switchingSeasonId === selectedSeason.id" class="inline-flex items-center gap-1 font-bold text-violet-600">
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
lädt
</span>
</div>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr wechseln</span>
<NativeSelect v-model="selectedSeasonSelectValue" :options="seasonOptions" />
</label>
</template>
<div v-else class="px-5 py-8 text-center">
<CalendarPlus class="mx-auto h-7 w-7 text-violet-300" />
<p class="mt-3 text-sm text-slate-500">Noch keine Award-Jahre aus der API geladen.</p>
<Button class="mt-5 gap-2" size="sm" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</Button>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { CalendarPlus, LoaderCircle, PlusCircle } from '@lucide/vue'
import type { AdminSeasonListItem } from '../../types/awards'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
const props = defineProps<{
seasons: AdminSeasonListItem[]
selectedSeasonId: number | null
openCreateModal: () => void
loadSeasonDetail: (seasonId: number) => Promise<void>
}>()
const switchingSeasonId = ref<number | null>(null)
const selectedSeason = computed(() =>
props.seasons.find((season) => season.id === props.selectedSeasonId) ?? props.seasons[0] ?? null,
)
const seasonOptions = computed(() =>
props.seasons.map((season) => ({
label: `${season.year} · ${season.name}`,
value: season.id,
})),
)
const selectedSeasonSelectValue = computed({
get: () => props.selectedSeasonId,
set: (value) => {
if (typeof value === 'number') {
void selectSeason(value)
}
},
})
async function selectSeason(seasonId: number) {
if (seasonId === props.selectedSeasonId || switchingSeasonId.value) {
return
}
switchingSeasonId.value = seasonId
try {
await props.loadSeasonDetail(seasonId)
} finally {
switchingSeasonId.value = null
}
}
</script>
@@ -0,0 +1,37 @@
<template>
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="$emit('confirm')">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { TriangleAlert } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
defineProps<{
candidate: AdminCandidateItem | null
deleting: boolean
}>()
defineEmits<{
close: []
confirm: []
}>()
</script>
@@ -0,0 +1,70 @@
<template>
<Modal :open="open" :title="title" subtitle="Anzeigename und Handle sind Pflicht." @close="$emit('close')">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<NativeSelect :model-value="form.categoryId" :options="categoryOptions" @update:model-value="$emit('update:categoryId', Number($event) || 0)" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input :value="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" @input="$emit('update:displayName', ($event.target as HTMLInputElement).value)" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input :value="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" @input="$emit('update:channelSlug', ($event.target as HTMLInputElement).value)" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<select :value="selectedPlatformValue" class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" @change="$emit('platform-selection', $event)">
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
<option value="custom">Eigene Plattform</option>
</select>
</label>
</div>
<label v-if="selectedPlatformValue === 'custom'" class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
</label>
</div>
<template #footer>
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
<Button :disabled="saving || !canSave" @click="$emit('save')">{{ saving ? 'Speichert ' : 'Speichern' }}</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { SocialIconOption } from '../../lib/socialIcons'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
open: boolean
title: string
form: {
categoryId: number
displayName: string
channelSlug: string
platform: string
}
categoryOptions: Array<{ label: string; value: number }>
candidatePlatformOptions: SocialIconOption[]
selectedPlatformValue: string
canSave: boolean
saving: boolean
}>()
defineEmits<{
close: []
save: []
'update:categoryId': [value: number]
'update:displayName': [value: string]
'update:channelSlug': [value: string]
'update:platform': [value: string]
'platform-selection': [event: Event]
}>()
</script>
@@ -0,0 +1,45 @@
<template>
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
<label class="relative block flex-1">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
:value="search"
type="text"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name, Handle oder Plattform suchen …"
@input="$emit('update:search', ($event.target as HTMLInputElement).value)"
/>
</label>
<NativeSelect
:model-value="categoryFilter"
:options="categoryFilterOptions"
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="$emit('open-create')">
<UserPlus class="h-4 w-4" /> Kandidat anlegen
</Button>
</div>
</template>
<script setup lang="ts">
import { Search, UserPlus, X } from '@lucide/vue'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
search: string
categoryFilter: number | null
categoryFilterOptions: Array<{ label: string; value: number | null }>
}>()
defineEmits<{
'update:search': [value: string]
'update:categoryFilter': [value: number | null]
'clear-filters': []
'open-create': []
}>()
</script>
@@ -0,0 +1,114 @@
<template>
<div>
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
<div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat in dieser Kategorie
</p>
</div>
</div>
<div class="min-w-0">
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
<div class="flex gap-2 lg:justify-end">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
title="Bearbeiten"
@click="$emit('edit', candidate)"
>
<Pencil class="h-4 w-4" />
</button>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Löschen"
@click="$emit('delete', candidate)"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<div v-if="filteredCount === 0" class="px-6 py-12 text-center">
<p class="text-sm text-slate-500">
{{ totalCount === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
<Button class="mt-4 gap-2" @click="$emit('open-create')"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
</div>
</div>
<div v-if="filteredCount > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCount }}</span>
<div class="flex items-center gap-2">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page <= 1"
@click="$emit('update:page', page - 1)"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="$emit('update:page', page + 1)"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronLeft, ChevronRight, Pencil, Trash2, UserPlus } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
const props = defineProps<{
pagedCandidates: AdminCandidateItem[]
totalCount: number
filteredCount: number
page: number
totalPages: number
rangeStart: number
rangeEnd: number
categoryLabelMap: Record<number, string>
duplicateCandidateKeys: Map<string, number>
}>()
defineEmits<{
edit: [candidate: AdminCandidateItem]
delete: [candidate: AdminCandidateItem]
'update:page': [page: number]
'open-create': []
}>()
function isDuplicate(candidate: AdminCandidateItem) {
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
}
</script>
@@ -0,0 +1,52 @@
<template>
<Card id="content-basics" class="overflow-hidden p-0">
<div class="border-b border-violet-100 bg-gradient-to-r from-violet-50 via-white to-amber-50/60 px-6 py-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Basis</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Host & Hero-Informationen</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese Werte erscheinen direkt im Hero-Hostblock und im Community-Bereich.</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<Globe2 class="h-6 w-6 text-violet-500" />
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Basis speichern' }}
</Button>
</div>
</div>
</div>
<div class="grid gap-4 p-6 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Name</span>
<input v-model="form.hostDisplayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Tagline</span>
<input v-model="form.hostTagline" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 md:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span>
<input v-model="form.newsletterUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div>
</Card>
</template>
<script setup lang="ts">
import { Globe2, Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
function onSave() {
return props.saveSiteSettings('Basis & Host')
}
</script>
@@ -0,0 +1,53 @@
<template>
<Card id="content-faq" class="p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Support</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">FAQ verwalten</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Fragen und Antworten erscheinen im FAQ-Abschnitt der Landingpage.</p>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addFaqItem">
<Plus class="h-4 w-4" />
FAQ hinzufügen
</Button>
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'FAQ speichern' }}
</Button>
</div>
</div>
<div class="mt-6 space-y-3">
<div v-for="(item, index) in form.faq" :key="`faq-${index}`" class="space-y-3 rounded-[22px] border border-violet-100 bg-white/80 p-4">
<input v-model="item.question" type="text" class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Frage" />
<textarea v-model="item.answer" rows="3" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Antwort" />
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-rose-100 bg-rose-50 px-4 text-rose-600 shadow-none hover:bg-rose-100" @click="removeFaqItem(index)">
<Trash2 class="h-4 w-4" />
Entfernen
</Button>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Plus, Save, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
addFaqItem: () => void
removeFaqItem: (index: number) => void
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
function onSave() {
return props.saveSiteSettings('FAQ')
}
</script>
@@ -0,0 +1,54 @@
<template>
<Card id="content-links" class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer & Kontakt</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Rechtliche Links und Kontaktwege</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<Link2 class="h-6 w-6 text-violet-500" />
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Links speichern' }}
</Button>
</div>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz E-Mail</span>
<input v-model="form.privacyEmail" type="email" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kontakt URL</span>
<input v-model="form.contactUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Impressum URL</span>
<input v-model="form.imprintUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Sponsoren & Partner URL</span>
<input v-model="form.sponsorsUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div>
</Card>
</template>
<script setup lang="ts">
import { Link2, Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
function onSave() {
return props.saveSiteSettings('Footer & Kontakt')
}
</script>
@@ -0,0 +1,53 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[120] flex items-center justify-center bg-[#25123f]/70 px-4 py-8 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-[34px] border border-violet-100 bg-white shadow-[0_34px_100px_rgba(38,18,63,0.35)]">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-r from-[#f8f2ff] via-white to-[#fff1f8] px-7 py-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Rechtliches Preview</p>
<h3 class="mt-2 text-xl font-bold text-slate-900">Datenschutzerklärung</h3>
<p class="mt-2 text-sm text-slate-500">Stand: {{ updatedLabel }}</p>
</div>
<button
type="button"
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
aria-label="Preview schließen"
@click="$emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] space-y-4 overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<p
v-for="(block, index) in blocks"
:key="`privacy-modal-block-${index}`"
class="whitespace-pre-wrap rounded-[18px] border border-violet-50 bg-white/86 px-4 py-3 shadow-sm"
>
{{ block }}
</p>
<p v-if="!blocks.length" class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Noch kein Datenschutztext eingetragen.
</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { X } from '@lucide/vue'
defineProps<{
open: boolean
blocks: string[]
updatedLabel: string
}>()
defineEmits<{
close: []
}>()
</script>
@@ -0,0 +1,69 @@
<template>
<section id="content-privacy">
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Rechtstexte</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Datenschutzerklärung</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Großes Textfeld für den kompletten Inhalt. Die Landingpage übernimmt denselben Text.</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<ShieldCheck class="h-6 w-6 text-violet-500" />
<div class="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="$emit('open-preview')">
<Eye class="h-4 w-4" />
Preview öffnen
</Button>
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Datenschutz speichern' }}
</Button>
</div>
</div>
</div>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
<div class="rounded-[24px] border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-[#f8eefb] p-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Aktualisiert von</p>
<p class="mt-2 text-lg font-semibold text-violet-900">{{ updatedBy || 'Unbekannt' }}</p>
</div>
<div class="rounded-[24px] border border-violet-100 bg-white p-4">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Zeitpunkt</p>
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
</div>
</div>
<label class="mt-6 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz Inhalt</span>
<textarea
v-model="form.privacyPolicyContent"
rows="24"
class="w-full rounded-[28px] border border-violet-200 bg-[#fcfbff] px-5 py-4 text-sm leading-7 text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Datenschutztext..."
/>
</label>
</Card>
</section>
</template>
<script setup lang="ts">
import { Eye, Save, ShieldCheck } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
updatedBy: string | null
updatedLabel: string
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': []
}>()
function onSave() {
return props.saveSiteSettings('Datenschutzerklärung')
}
</script>
@@ -0,0 +1,34 @@
<template>
<Card class="p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Bereiche</p>
<div class="mt-4 grid gap-2">
<a
v-for="section in sections"
:key="section.href"
:href="section.href"
:class="section.primary ? primaryClasses : secondaryClasses"
>
{{ section.label }}
</a>
</div>
</Card>
</template>
<script setup lang="ts">
import Card from '../ui/Card.vue'
type SectionLink = {
href: string
label: string
primary?: boolean
}
defineProps<{
sections: SectionLink[]
}>()
const primaryClasses =
'rounded-2xl border border-violet-100 bg-violet-50/70 px-4 py-3 text-sm font-semibold text-violet-800 no-underline transition hover:bg-violet-100'
const secondaryClasses =
'rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm font-semibold text-slate-700 no-underline transition hover:bg-violet-50'
</script>
@@ -0,0 +1,143 @@
<template>
<Card id="content-socials" class="p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Community</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Social Links</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Plattform, Label und URL werden auf der Landingpage als Social Icons angezeigt.</p>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addSocialLink">
<Plus class="h-4 w-4" />
Link hinzufügen
</Button>
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Socials speichern' }}
</Button>
</div>
</div>
<div class="mt-6 space-y-4">
<p v-if="iconUploadError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ iconUploadError }}</p>
<div v-for="(social, index) in form.socialLinks" :key="`${social.platform}-${index}`" class="rounded-[28px] border border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-white p-5 shadow-[0_16px_42px_rgba(124,92,255,0.08)]">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-sm font-black text-violet-700">{{ index + 1 }}</span>
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-500">Social Link</p>
<h3 class="text-lg font-semibold text-slate-900">{{ social.label || 'Neuer Link' }}</h3>
</div>
</div>
<Button variant="ghost" size="sm" class="h-10 w-10 rounded-2xl border border-rose-100 bg-rose-50 p-0 text-rose-600 shadow-none hover:bg-rose-100" aria-label="Social Link entfernen" @click="removeSocialLink(index)">
<Trash2 class="h-4 w-4" />
</Button>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)_300px]">
<label class="space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="social.label" type="text" class="h-11 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
</label>
<label class="space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform auswählen</span>
<select :value="selectedSocialIconValue(social)" class="h-11 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" @change="handleSocialIconSelection($event, index)">
<optgroup v-for="group in SOCIAL_ICON_OPTION_GROUPS" :key="group.label" :label="group.label">
<option v-for="option in group.options" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</optgroup>
<option value="custom">Andere Plattform / eigenes Icon</option>
</select>
<span class="block text-xs text-slate-400">Bekannte Plattformen nutzen automatisch Bibliotheks-Icons. Neue oder eingestellte Plattformen bleiben über Custom-Key plus Upload möglich.</span>
</label>
<div class="space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Custom Icon</span>
<label class="group flex h-11 min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
<span class="flex min-w-0 items-center gap-2">
<span class="grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-xl bg-violet-100 text-violet-600">
<Upload class="h-4 w-4" />
</span>
<span class="truncate">{{ isUploadedIcon(social.icon) ? 'Icon ersetzen' : 'Icon hochladen' }}</span>
</span>
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="sr-only" @change="handleSocialIconUpload($event, index)" />
</label>
</div>
<label v-if="selectedSocialIconValue(social) === 'custom'" class="space-y-2 xl:col-span-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Andere Plattform / eigener Key</span>
<input v-model="social.platform" type="text" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. cake, booth, neue-plattform" />
<span class="block text-xs text-slate-400">Für neue oder nicht mehr gepflegte Plattformen: Key speichern, eigenes Icon hochladen oder Stern-Fallback nutzen.</span>
</label>
<label class="space-y-2 xl:col-span-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">URL</span>
<input v-model="social.url" type="url" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="https://..." />
</label>
</div>
<div v-if="hasSocialIconPreview(social)" class="mt-4 grid gap-4 rounded-[22px] border border-violet-100 bg-white/82 p-4">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Icon Preview</p>
<p class="mt-1 text-xs text-slate-500">Aktives Icon: {{ socialIconModeLabel(social) }}. Unbekannte Plattformen bleiben verlinkt und nutzen ein Fallback, bis du ein Custom Icon hochlädst.</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="flex items-center justify-between gap-4 rounded-[18px] border border-violet-100 bg-violet-50/60 p-3">
<span class="flex items-center gap-3">
<span class="grid h-9 w-9 place-items-center rounded-[11px] bg-[#f1ecfb]">
<img v-if="isUploadedIcon(social.icon)" :src="social.icon" :alt="`${social.label || social.platform} Host Icon Preview`" class="h-[22px] w-[22px] object-contain" />
<svg v-else-if="socialSimpleIconPath(social)" class="h-[22px] w-[22px]" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social)"><path :d="socialSimpleIconPath(social)" /></svg>
<span v-else class="text-lg font-black text-violet-600"></span>
</span>
<span class="text-sm font-semibold text-slate-700">Host Größe</span>
</span>
<input v-model="social.showOnHost" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
</label>
<label class="flex items-center justify-between gap-4 rounded-[18px] border border-violet-100 bg-white p-3">
<span class="flex items-center gap-3">
<span class="grid h-[46px] w-[46px] place-items-center rounded-[13px] border border-[#e9e0f8] bg-white shadow-[0_4px_12px_rgba(124,86,196,0.08)]">
<img v-if="isUploadedIcon(social.icon)" :src="social.icon" :alt="`${social.label || social.platform} Community Icon Preview`" class="h-6 w-6 object-contain" />
<svg v-else-if="socialSimpleIconPath(social)" class="h-6 w-6" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social)"><path :d="socialSimpleIconPath(social)" /></svg>
<span v-else class="text-xl font-black text-violet-600"></span>
</span>
<span class="text-sm font-semibold text-slate-700">Community Größe</span>
</span>
<input v-model="social.showOnCommunity" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
</label>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500 lg:col-span-4">
<span>PNG, JPG, WebP oder SVG · max. 256 KB · max. 512 x 512 px</span>
<button v-if="social.icon" type="button" class="font-semibold text-rose-500 transition hover:text-rose-700" @click="clearSocialIcon(index)">Icon entfernen</button>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Plus, Save, Trash2, Upload } from '@lucide/vue'
import { SOCIAL_ICON_OPTION_GROUPS } from '../../lib/socialIcons'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
const props = defineProps<{
form: AdminContentForm
saving: boolean
iconUploadError: string
addSocialLink: () => void
removeSocialLink: (index: number) => void
isUploadedIcon: (icon: string) => boolean
selectedSocialIconValue: (social: SocialLinkForm) => string
handleSocialIconSelection: (event: Event, index: number) => void
hasSocialIconPreview: (social: SocialLinkForm) => boolean
socialIconModeLabel: (social: SocialLinkForm) => string
socialSimpleIconPath: (social: SocialLinkForm) => string
socialSimpleIconColor: (social: SocialLinkForm) => string
handleSocialIconUpload: (event: Event, index: number) => Promise<void>
clearSocialIcon: (index: number) => void
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
function onSave() {
return props.saveSiteSettings('Social Links')
}
</script>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import Card from '../ui/Card.vue'
defineProps<{
activities: Array<{ label: string; age: string }>
}>()
</script>
<template>
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was gerade passiert ist</h2>
</div>
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[24px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
</div>
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine aktuellen Audit-Aktivitäten vorhanden.
</p>
</div>
</Card>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineProps<{
operationChecks: Array<{ label: string; value: number; to: string; state: string; note: string }>
}>()
</script>
<template>
<section class="grid gap-4 lg:grid-cols-3">
<RouterLink
v-for="check in operationChecks"
:key="check.label"
:to="check.to"
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="{
'border-emerald-100': check.state === 'ok',
'border-amber-100': check.state === 'warn',
'border-rose-100': check.state === 'danger',
}"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
{{ check.value }}
</strong>
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
</div>
</div>
</RouterLink>
</section>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import Card from '../ui/Card.vue'
defineProps<{
metricCards: Array<{ label: string; value: number; note: string; source: string; icon: unknown }>
liveSummary: string
liveStatusBadge: string
openRiskCount: number
openReviewCount: number
}>()
</script>
<template>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-white/80 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-500">Live-Lage</p>
<p class="mt-1 max-w-3xl text-sm leading-5 text-slate-600">
{{ liveSummary }}
</p>
</div>
<div
class="inline-flex shrink-0 items-center rounded-xl border px-3 py-2 text-xs font-bold"
:class="openRiskCount > 0 ? 'border-rose-200 bg-rose-50 text-rose-700' : openReviewCount > 0 ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'"
>
{{ liveStatusBadge }}
</div>
</div>
</div>
<div class="grid gap-3 p-4 md:grid-cols-2">
<div
v-for="metric in metricCards"
:key="metric.label"
class="rounded-2xl border border-violet-100 bg-white/90 p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-2 block text-2xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-700">
<component :is="metric.icon" class="h-4 w-4" />
</div>
</div>
<div class="mt-3 rounded-xl border border-violet-100 bg-violet-50/50 px-3 py-2">
<p class="text-xs font-semibold leading-5 text-slate-700">{{ metric.note }}</p>
<p class="mt-0.5 text-[11px] text-slate-500">Quelle: {{ metric.source }}</p>
</div>
</div>
</div>
</Card>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Clock3 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Card from '../ui/Card.vue'
defineProps<{
priorityActions: Array<{ label: string; value: number; to: string; hint: string; icon: unknown; tone: string }>
}>()
</script>
<template>
<Card class="p-6">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Schnellzugriffe</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was zuerst?</h2>
</div>
<Clock3 class="h-6 w-6 text-amber-500" />
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="item in priorityActions"
:key="item.label"
:to="item.to"
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<div
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
:class="{
'bg-violet-100 text-violet-700': item.tone === 'violet',
'bg-rose-100 text-rose-700': item.tone === 'rose',
'bg-amber-100 text-amber-700': item.tone === 'amber',
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
}"
>
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ item.label }}</p>
<p class="truncate text-sm text-slate-500">{{ item.hint }}</p>
</div>
</div>
<strong class="rounded-full border border-violet-100 bg-white px-3 py-1 text-violet-800">{{ item.value }}</strong>
</div>
</RouterLink>
</div>
</Card>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import Card from '../ui/Card.vue'
defineProps<{
totalCategoryVotes: number
maxCategoryVotes: number
topCategories: Array<{ category: string; votes: number }>
}>()
</script>
<template>
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Stimmen</h2>
</div>
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
</div>
<div class="mt-6 space-y-4">
<div
v-for="(category, index) in topCategories"
:key="category.category"
class="rounded-[24px] border border-violet-100 bg-white/90 p-4"
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">#{{ index + 1 }}</p>
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
</div>
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
<div
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
/>
</div>
</div>
</div>
</Card>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { BarChart3 } from '@lucide/vue'
import Card from '../ui/Card.vue'
defineProps<{
year: number
yearTotals: Array<{ label: string; value: number; note: string; icon: unknown }>
}>()
</script>
<template>
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Jahreszahlen</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Gesamtmetriken {{ year }}</h2>
</div>
<BarChart3 class="h-6 w-6 text-amber-500" />
</div>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<div
v-for="item in yearTotals"
:key="item.label"
class="rounded-[22px] border border-violet-100 bg-white/90 p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.label }}</p>
<strong class="mt-2 block text-3xl text-violet-900">{{ item.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-4 w-4" />
</div>
</div>
<p class="mt-3 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
</div>
</Card>
</template>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { Sparkles, X } from '@lucide/vue'
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
import { useAdminReviewsManager } from './useAdminReviewsManager'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
}>()
const {
reviewSaving,
adminMessage,
adminError,
reviewForms,
seasonDetail,
reviewFilter,
categoryFilter,
selectedNominationId,
candidatePlatformOptions,
filteredNominations,
selectedNomination,
reviewStats,
reviewedNominations,
categoryOptions,
selectedCandidateCollision,
selectedRelatedPendingNominations,
selectedNominationSignalSummary,
canApproveSelected,
approveNomination,
rejectNomination,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
} = useAdminReviewsManager()
function closeModal() {
emit('close')
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) {
closeModal()
}
}
watch(
() => props.open,
(open) => {
if (typeof document !== 'undefined') {
document.body.style.overflow = open ? 'hidden' : ''
}
},
{ immediate: true },
)
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
@click.self="closeModal"
>
<div class="absolute inset-0 bg-violet-950/35 backdrop-blur-sm" @click="closeModal" />
<section class="relative z-10 flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-[28px] border border-violet-200/80 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
<header class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f7f2ff,#fff7ed)] px-5 py-4 sm:px-6">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Sparkles class="h-4.5 w-4.5" />
</span>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
</div>
<h2 class="mt-3 text-2xl font-bold text-slate-950">Nominierung entscheiden</h2>
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
</p>
</div>
<button
type="button"
class="grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-white/80 hover:text-violet-700"
aria-label="Review-Dialog schließen"
@click="closeModal"
>
<X class="h-5 w-5" />
</button>
</header>
<div class="min-h-0 overflow-y-auto bg-white">
<AdminReviewsQueueHeader
v-model:review-filter="reviewFilter"
v-model:category-filter="categoryFilter"
:total-pending="seasonDetail.pendingNominations.length"
:visible-count="filteredNominations.length"
:review-stats="reviewStats"
:category-options="categoryOptions"
/>
<div class="space-y-4 p-4 sm:p-6">
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
<AdminReviewDecisionPanel
:nomination="selectedNomination"
:review-saving="reviewSaving"
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
:candidate-platform-options="candidatePlatformOptions"
:selected-candidate-collision="selectedCandidateCollision"
:selected-related-pending-nominations="selectedRelatedPendingNominations"
:signal-summary="selectedNominationSignalSummary"
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
:can-approve-selected="canApproveSelected"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
/>
</div>
<details class="rounded-[24px] border border-violet-100 bg-violet-50/40">
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
Letzte Entscheidungen anzeigen
</summary>
<div class="border-t border-violet-100 p-5">
<AdminReviewsHistorySection
:reviewed-nominations="reviewedNominations"
:reviewed-total="seasonDetail.reviewedNominations.length"
/>
</div>
</details>
</div>
</div>
</section>
</div>
</Teleport>
</template>
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { KeyRound, Loader2, LockKeyhole, Save, ShieldCheck, Wrench } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
form: AdminOperationalSettingsForm
loading: boolean
saving: boolean
error: string
success: string
demoPassword: string
demoPasswordHint: string
demoPasswordSet: boolean
demoManagedByDatabase: boolean
demoCredentialsComplete: boolean
summary: AdminSettingsStatusSummary[]
dirty: boolean
canManage: boolean
}>()
const emit = defineEmits<{
save: []
'update:demoPassword': [value: string]
}>()
function readInput(event: Event) {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
}
function toneClasses(tone: AdminSettingsTone) {
if (tone === 'good') {
return 'border-emerald-100 bg-emerald-50/55 text-emerald-800'
}
if (tone === 'warning') {
return 'border-amber-100 bg-amber-50/70 text-amber-800'
}
if (tone === 'danger') {
return 'border-rose-100 bg-rose-50/70 text-rose-800'
}
return 'border-violet-100 bg-violet-50/60 text-violet-800'
}
</script>
<template>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Demo & Wartung</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Öffentlichen Zugriff steuern</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Demo-Schutz und Wartungsmodus sind echte Betriebs-Schalter. Änderungen werden in der Datenbank gespeichert und sofort für die Public-App verwendet.
</p>
</div>
<Button :disabled="loading || saving || !canManage || !dirty" class="gap-2 rounded-2xl px-5" @click="emit('save')">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<LockKeyhole v-else-if="!canManage" class="h-4 w-4" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : !canManage ? 'Nur Owner' : dirty ? 'Zugriff speichern' : 'Gespeichert' }}
</Button>
</div>
</div>
<div v-if="loading" class="flex items-center gap-3 p-6 text-sm font-semibold text-violet-700">
<Loader2 class="h-4 w-4 animate-spin" />
Lade Demo- und Wartungseinstellungen ...
</div>
<div v-else class="space-y-5 p-5">
<p
v-if="!canManage"
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
Demo-Zugang und Wartungsmodus sind Owner-Controls. Du kannst den Status ansehen, aber nicht bearbeiten.
</p>
<p
v-else-if="dirty"
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
>
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
</p>
<div class="grid gap-3 md:grid-cols-3">
<div
v-for="item in summary"
:key="item.label"
class="rounded-2xl border px-4 py-3"
:class="toneClasses(item.tone)"
>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{{ item.label }}</p>
<strong class="mt-1 block text-lg text-slate-950">{{ item.value }}</strong>
<p class="mt-1 text-xs leading-5">{{ item.note }}</p>
</div>
</div>
<div class="grid gap-5 xl:grid-cols-2">
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<KeyRound class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Demo Login</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Landingpage temporär sperren</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Für Demo-Deployments oder nicht öffentliche Tests. Danach kann der Schutz hier wieder aus.
</p>
</div>
</div>
<AdminSettingsToggle
v-model="form.demoLoginEnabled"
label="Demo Login aktivieren"
:disabled="saving || !canManage"
/>
</div>
<p
v-if="form.demoLoginEnabled && !demoCredentialsComplete"
class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
>
Zum Aktivieren fehlen noch Login, Twitch-ID, Anzeigename oder ein gesetztes Passwort.
</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Login</span>
<input v-model="form.demoLoginIdentifier" :disabled="saving || !canManage" type="text" autocomplete="username" placeholder="E-Mail oder Username" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
<span class="mt-2 block text-xs leading-5 text-slate-500">E-Mail, Username oder dieselbe Kennung wie die Twitch-ID.</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
<input :value="demoPassword" :disabled="saving || !canManage" type="password" autocomplete="new-password" placeholder="Leer lassen = behalten" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" @input="emit('update:demoPassword', readInput($event))" />
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Twitch-ID</span>
<input v-model="form.demoLoginTwitchUserId" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="form.demoLoginDisplayName" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
</label>
</div>
<div class="mt-4 flex flex-wrap gap-2 text-xs font-semibold">
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">{{ demoManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}</span>
<span class="rounded-full px-3 py-1" :class="demoPasswordSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ demoPasswordSet ? 'Passwort vorhanden' : 'Passwort fehlt' }}
</span>
</div>
</section>
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl" :class="form.maintenanceModeEnabled ? 'bg-amber-100 text-amber-700' : 'bg-violet-100 text-violet-700'">
<Wrench class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em]" :class="form.maintenanceModeEnabled ? 'text-amber-600' : 'text-violet-500'">Wartungsmodus</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Sternenpause anzeigen</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Besucher landen auf der Wartungsseite. Login und Admin bleiben erreichbar.
</p>
</div>
</div>
<AdminSettingsToggle
v-model="form.maintenanceModeEnabled"
label="Wartungsmodus aktivieren"
tone="amber"
:disabled="saving || !canManage"
/>
</div>
<div class="mt-5 space-y-4">
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel der Wartungsseite</span>
<input v-model="form.maintenanceTitle" :disabled="saving || !canManage" type="text" maxlength="120" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Hinweistext</span>
<textarea v-model="form.maintenanceMessage" :disabled="saving || !canManage" rows="4" maxlength="600" class="mt-2 w-full resize-none rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"></textarea>
</label>
<RouterLink to="/maintenance?preview=true" class="inline-flex h-11 items-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
<ShieldCheck class="h-4 w-4" />
Wartungsseite ansehen
</RouterLink>
</div>
</section>
</div>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
</Card>
</template>
@@ -1,50 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import { Sparkles } from '@lucide/vue'
defineProps<{
const props = defineProps<{
eyebrow?: string
title: string
description: string
/** Dekoratives Icon oben rechts analog zu den Frontend-Bannern */
title?: string
description?: string
icon?: Component
}>()
const label = computed(() => props.eyebrow ?? props.title ?? 'Admin')
</script>
<template>
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-9 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-11">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<g fill="#f6b938" opacity="0.7">
<circle cx="74%" cy="24%" r="3" />
<circle cx="92%" cy="62%" r="2.5" />
<circle cx="63%" cy="78%" r="2.5" />
</g>
<g fill="#a78bff" opacity="0.6">
<circle cx="83%" cy="40%" r="2.5" />
<circle cx="69%" cy="50%" r="2" />
</g>
</svg>
<div
v-if="icon"
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
>
<div class="grid h-40 w-40 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
<component :is="icon" class="h-[4.5rem] w-[4.5rem]" />
</div>
</div>
<div class="relative max-w-2xl space-y-4">
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
{{ eyebrow ?? 'Admin' }}
<section
class="rounded-2xl border border-violet-100 bg-white/85 px-4 py-3 shadow-[0_12px_32px_rgba(124,92,255,0.07)]"
:aria-label="label"
>
<div class="flex min-w-0 items-start gap-3">
<span
v-if="icon"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600"
>
<component :is="icon" class="h-4 w-4" />
</span>
<h1 class="font-[Cormorant_Garamond] text-4xl leading-[1.02] text-violet-800 sm:text-5xl">
{{ title }}
</h1>
<p class="max-w-xl text-sm leading-6 text-slate-600 sm:text-base">{{ description }}</p>
<div class="min-w-0">
<p class="truncate text-[11px] font-bold uppercase tracking-[0.18em] text-violet-600">
{{ label }}
</p>
<p v-if="description" class="mt-1 max-w-4xl text-sm leading-5 text-slate-500">
{{ description }}
</p>
</div>
</div>
</section>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { CheckCircle2, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
defineProps<{
nomination: AdminNominationReviewItem | null
reviewSaving: number | null
reviewForm: {
displayName: string
channelSlug: string
platform: string
reviewNote: string
} | null
candidatePlatformOptions: Array<{ key: string; label: string }>
selectedCandidateCollision: AdminCandidateItem | null
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
signalSummary?: {
submissions: number
uniqueSubmitters: number
platforms: string[]
} | null
streamUrl?: string
canApproveSelected: boolean
selectedPlatformValue: (platform: string) => string
}>()
const emit = defineEmits<{
'platform-change': [event: Event]
approve: [nominationId: number]
reject: [nominationId: number]
}>()
</script>
<template>
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
ID {{ nomination.id }}
</div>
</div>
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
<a
:href="streamUrl"
target="_blank"
rel="noopener"
class="mt-2 inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
>
<span class="truncate">{{ streamUrl }}</span>
</a>
</div>
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Einreichungen</p>
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige User</p>
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong>
</div>
</div>
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input
v-model="reviewForm.displayName"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Anzeigename"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input
v-model="reviewForm.channelSlug"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="channel"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<select
:value="selectedPlatformValue(reviewForm.platform)"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@change="emit('platform-change', $event)"
>
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
<option value="custom">Eigene Plattform</option>
</select>
</label>
</div>
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
<input
v-model="reviewForm.platform"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="z. B. Cake, Booth, neue Plattform"
/>
</label>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
</p>
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
<ul class="mt-2 space-y-1">
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.reviewNote || related.streamUrl || 'ohne Notiz' }}
</li>
</ul>
</div>
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
</p>
<label class="mt-3 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
<textarea
v-model="reviewForm.reviewNote"
rows="3"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Warum wurde übernommen oder verworfen?"
/>
</label>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)">
<Trash2 class="mr-2 h-4 w-4" />
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" @click="emit('approve', nomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { AdminNominationReviewItem } from '../../types/awards'
defineProps<{
reviewedNominations: AdminNominationReviewItem[]
reviewedTotal: number
}>()
</script>
<template>
<div class="rounded-[26px] border border-violet-100 bg-violet-50/50 p-5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Letzte Entscheidungen</p>
<h3 class="mt-1 text-lg font-bold text-slate-900">Review-Historie</h3>
</div>
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
{{ reviewedTotal }} entschieden
</span>
</div>
<div class="mt-4 space-y-3">
<div v-for="nomination in reviewedNominations" :key="`reviewed-${nomination.id}`" class="rounded-2xl border border-violet-100 bg-white/90 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ nomination.candidateText }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'">
{{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }}
</span>
</div>
<p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p>
<p class="mt-2 text-xs text-slate-500">
{{ nomination.reviewedByTwitchId || 'Admin' }} · {{ nomination.reviewedAt ? new Date(nomination.reviewedAt).toLocaleString('de-DE') : 'ohne Zeitstempel' }}
</p>
</div>
<p v-if="reviewedNominations.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-6 text-sm text-slate-500">
Noch keine gespeicherten Review-Entscheidungen.
</p>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { Search } from '@lucide/vue'
defineProps<{
reviewFilter: string
categoryFilter: number | null
totalPending: number
visibleCount: number
reviewStats: Array<{ label: string; value: number }>
categoryOptions: Array<{ id: number; label: string; count: number }>
}>()
const emit = defineEmits<{
'update:reviewFilter': [value: string]
'update:categoryFilter': [value: number | null]
}>()
</script>
<template>
<div class="border-b border-violet-100 bg-white/75 p-6">
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Offene Nominierungen</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
</p>
</div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
:value="reviewFilter"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Nach Kategorie, Kandidat oder Nutzer suchen"
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ visibleCount }} / {{ totalPending }} sichtbar
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="emit('update:categoryFilter', null)"
>
Alle Kategorien
</button>
<button
v-for="category in categoryOptions"
:key="category.id"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="emit('update:categoryFilter', category.id)"
>
{{ category.label }} · {{ category.count }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { AdminNominationReviewItem } from '../../types/awards'
defineProps<{
nominations: AdminNominationReviewItem[]
totalPending: number
selectedNominationId: number | null
}>()
const emit = defineEmits<{
select: [nominationId: number]
}>()
</script>
<template>
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
<button
v-for="nomination in nominations"
:key="nomination.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
@click="emit('select', nomination.id)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
{{ nomination.categoryName }}
</span>
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
ID {{ nomination.id }}
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
</div>
</button>
<p v-if="totalPending === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
</p>
<p v-else-if="nominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Fälle passen zum aktuellen Filter.
</p>
</div>
</template>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { CheckCircle2, ExternalLink, Trash2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Button from '../ui/Button.vue'
import type { AdminRiskFlag } from '../../types/awards'
import type { RiskMetadataItem, RiskResolutionStatus } from './useAdminRiskManager'
import { riskAgeLabel, riskSeverityClass } from './useAdminRiskManager'
defineProps<{
riskFlag: AdminRiskFlag | null
metadataItems: RiskMetadataItem[]
decisionNote: string
decisionNoteLength: number
decisionReady: boolean
saving: number | null
}>()
const emit = defineEmits<{
'update:decisionNote': [value: string]
decide: [riskFlagId: number, status: RiskResolutionStatus]
}>()
</script>
<template>
<section
v-if="riskFlag"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="riskSeverityClass(riskFlag.severity)">
{{ riskFlag.severity }}
</span>
<span class="rounded-full bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
{{ riskAgeLabel(riskFlag) }}
</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
{{ riskFlag.source }} · {{ riskFlag.type }}
</span>
</div>
<h2 class="mt-3 text-xl font-bold text-slate-900">{{ riskFlag.summary }}</h2>
<p class="mt-2 text-sm text-slate-500">
{{ riskFlag.twitchUserId || 'unbekannter User' }} · {{ riskFlag.createdFromIp }} · {{ new Date(riskFlag.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
Risk Flag #{{ riskFlag.id }}
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
<p class="text-xs font-semibold text-slate-500">Metadaten</p>
<dl v-if="metadataItems.length" class="mt-3 space-y-2">
<div
v-for="item in metadataItems"
:key="item.key"
class="rounded-xl bg-white/80 px-3 py-2"
>
<dt class="text-xs font-semibold text-slate-400">{{ item.key }}</dt>
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
</div>
</dl>
<p v-else class="mt-3 rounded-xl border border-dashed border-violet-100 px-3 py-4 text-sm text-slate-500">
Keine zusätzlichen Metadaten hinterlegt.
</p>
</div>
<div class="space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
<div>
<p class="text-xs font-semibold text-slate-500">Betroffene Bereiche</p>
<div v-if="riskFlag.entityLinks.length" class="mt-3 flex flex-wrap gap-2">
<RouterLink
v-for="link in riskFlag.entityLinks"
:key="`${link.entityType}-${link.entityId}-${link.to}`"
:to="link.to"
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-sm font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
>
<ExternalLink class="h-4 w-4" />
{{ link.label }}
</RouterLink>
</div>
<p v-else class="mt-3 rounded-xl border border-dashed border-violet-100 px-3 py-4 text-sm text-slate-500">
Kein direkter Zielbereich hinterlegt.
</p>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold text-slate-500">Entscheidungsnotiz</span>
<textarea
:value="decisionNote"
rows="7"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Kurz festhalten, was geprüft wurde und warum du so entscheidest."
@input="emit('update:decisionNote', ($event.target as HTMLTextAreaElement).value)"
/>
</label>
<p class="mt-2 text-xs" :class="decisionReady ? 'text-slate-500' : 'text-rose-600'">
{{ decisionNoteLength }}/500 Zeichen · Mindestens 3 Zeichen für erledigt/verworfen.
</p>
</div>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button
:disabled="saving === riskFlag.id || !decisionReady"
variant="secondary"
@click="emit('decide', riskFlag.id, 'dismissed')"
>
<Trash2 class="mr-2 h-4 w-4" />
{{ saving === riskFlag.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button
:disabled="saving === riskFlag.id || !decisionReady"
@click="emit('decide', riskFlag.id, 'resolved')"
>
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ saving === riskFlag.id ? 'Speichert ...' : 'Erledigt markieren' }}
</Button>
</div>
</section>
<section v-else class="rounded-[26px] border border-dashed border-violet-100 bg-white/70 p-8 text-sm text-slate-500">
Keine offene Auswahl. Sobald ein Risiko-Hinweis auftaucht, erscheint hier die Detailprüfung.
</section>
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, ExternalLink, RotateCcw } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import Button from '../ui/Button.vue'
import type { AdminRiskFlag } from '../../types/awards'
import type { RiskResolutionStatus } from './useAdminRiskManager'
import { riskSeverityClass, riskStatusLabel } from './useAdminRiskManager'
defineProps<{
riskHistory: AdminRiskFlag[]
totalHistory: number
historyStatusFilter: 'all' | 'resolved' | 'dismissed'
historyStatusFilters: Array<{ key: 'all' | 'resolved' | 'dismissed'; label: string; count: number }>
historyStats: Array<{ label: string; value: number }>
saving: number | null
page: number
pageLabel: string
hasPrevious: boolean
hasMore: boolean
}>()
const emit = defineEmits<{
'update:historyStatusFilter': [value: 'all' | 'resolved' | 'dismissed']
decide: [riskFlagId: number, status: RiskResolutionStatus]
page: [page: number]
}>()
</script>
<template>
<section class="border-t border-violet-100 bg-violet-50/25 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-lg font-bold text-slate-900">Entscheidungsprotokoll</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">
Geprüfte Hinweise mit Notiz, Reviewer und Reopen-Möglichkeit.
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in historyStatusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-2 text-xs font-semibold transition"
:class="historyStatusFilter === filter.key ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-violet-50 text-slate-600 hover:bg-white'"
@click="emit('update:historyStatusFilter', filter.key)"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
</div>
<div class="mt-5 grid gap-2 sm:grid-cols-3">
<div v-for="stat in historyStats" :key="stat.label" class="rounded-2xl border border-violet-100 bg-white/80 px-3 py-2">
<p class="text-xs font-semibold text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
<div class="mt-5 grid gap-3 xl:grid-cols-2">
<article
v-for="flag in riskHistory"
:key="flag.id"
class="rounded-[22px] border border-violet-100 bg-white/90 p-4 shadow-sm shadow-violet-100/40"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="flag.status === 'resolved' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'"
>
{{ riskStatusLabel(flag.status) }}
</span>
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="riskSeverityClass(flag.severity)">
{{ flag.severity }}
</span>
</div>
<h3 class="mt-3 text-base font-semibold text-slate-950">{{ flag.summary }}</h3>
<p class="mt-2 text-sm text-slate-500">
{{ flag.source }} · {{ flag.type }} · {{ flag.twitchUserId || 'unbekannter User' }}
</p>
</div>
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-3 text-violet-700 shadow-none hover:bg-violet-100"
:disabled="saving === flag.id"
@click="emit('decide', flag.id, 'open')"
>
<RotateCcw class="h-4 w-4" />
{{ saving === flag.id ? 'Öffnet ...' : 'Wieder öffnen' }}
</Button>
</div>
<div class="mt-4 grid gap-3 text-xs text-slate-500 sm:grid-cols-2">
<p>
<span class="block font-semibold text-slate-400">Entschieden von</span>
{{ flag.reviewedByTwitchId || 'nicht protokolliert' }}
</p>
<p>
<span class="block font-semibold text-slate-400">Zeitpunkt</span>
{{ flag.reviewedAt ? new Date(flag.reviewedAt).toLocaleString('de-DE') : 'nicht protokolliert' }}
</p>
</div>
<p class="mt-4 rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm text-slate-600">
{{ flag.reviewNote || 'Keine Review-Notiz gespeichert.' }}
</p>
<div v-if="flag.entityLinks.length" class="mt-3 flex flex-wrap gap-2">
<RouterLink
v-for="link in flag.entityLinks"
:key="`${flag.id}-${link.entityType}-${link.entityId}-${link.to}`"
:to="link.to"
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
>
<ExternalLink class="h-3.5 w-3.5" />
{{ link.label }}
</RouterLink>
</div>
</article>
<p v-if="totalHistory === 0" class="rounded-[22px] border border-dashed border-violet-100 bg-white/70 px-5 py-6 text-sm text-slate-500">
Noch keine entschiedenen Risikohinweise vorhanden.
</p>
<p v-else-if="riskHistory.length === 0" class="rounded-[22px] border border-dashed border-violet-100 bg-white/70 px-5 py-6 text-sm text-slate-500">
Keine Historien-Einträge passen zum aktuellen Filter.
</p>
</div>
<div class="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-white/75 px-3 py-2">
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-100"
:disabled="!hasPrevious"
@click="emit('page', page - 1)"
>
<ChevronLeft class="h-4 w-4" />
Zurück
</Button>
<span class="text-sm font-semibold text-slate-500">{{ pageLabel }}</span>
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-100"
:disabled="!hasMore"
@click="emit('page', page + 1)"
>
Weiter
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</section>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { RefreshCw, Search } from '@lucide/vue'
import Button from '../ui/Button.vue'
defineProps<{
riskFilter: string
severityFilter: 'all' | 'high' | 'medium' | 'low'
filteredCount: number
totalOpen: number
loadedLabel: string
loading: boolean
message: string
error: string
stats: Array<{ label: string; value: number; tone: string }>
severityFilters: Array<{ key: 'all' | 'high' | 'medium' | 'low'; label: string; count: number }>
}>()
const emit = defineEmits<{
'update:riskFilter': [value: string]
'update:severityFilter': [value: 'all' | 'high' | 'medium' | 'low']
refresh: []
}>()
function statToneClass(tone: string) {
if (tone === 'danger') return 'border-rose-100 bg-rose-50 text-rose-800'
if (tone === 'warning') return 'border-amber-100 bg-amber-50 text-amber-800'
if (tone === 'ok') return 'border-emerald-100 bg-emerald-50 text-emerald-800'
return 'border-violet-100 bg-violet-50 text-violet-800'
}
</script>
<template>
<section class="border-b border-violet-100 bg-white/70 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 class="text-xl font-bold text-slate-900">Risiko-Zentrale</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Offene Hinweise priorisieren, Entscheidungsnotizen setzen und geprüfte Fälle nachvollziehbar protokollieren.
</p>
<p class="mt-1 text-xs font-semibold text-slate-400">
Geladen: {{ loadedLabel }} · Quelle: /api/admin/risk-flags
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-2 text-sm font-semibold text-violet-800">
{{ filteredCount }} / {{ totalOpen }} sichtbar
</span>
<Button
variant="ghost"
size="sm"
class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100"
:disabled="loading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
{{ loading ? 'Lädt ...' : 'Aktualisieren' }}
</Button>
</div>
</div>
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div
v-for="stat in stats"
:key="stat.label"
class="rounded-2xl border px-4 py-3"
:class="statToneClass(stat.tone)"
>
<p class="text-xs font-semibold text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-2xl">{{ stat.value }}</strong>
</div>
</div>
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
:value="riskFilter"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Typ, User, IP, Summary oder Metadaten filtern"
@input="emit('update:riskFilter', ($event.target as HTMLInputElement).value)"
/>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="filter in severityFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-2 text-xs font-semibold transition"
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="emit('update:severityFilter', filter.key)"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
</div>
<p v-if="message" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ message }}
</p>
<p v-if="error" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ error }}
</p>
</section>
</template>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, Clock3, ListChecks, Trash2, X } from '@lucide/vue'
import type { AdminRiskFlag } from '../../types/awards'
import Button from '../ui/Button.vue'
import { riskAgeLabel, riskSeverityClass } from './useAdminRiskManager'
defineProps<{
riskFlags: AdminRiskFlag[]
totalOpen: number
selectedRiskFlagId: number | null
page: number
pageLabel: string
hasPrevious: boolean
hasMore: boolean
selectedBulkRiskFlagIds: number[]
bulkReviewNote: string
bulkSaving: boolean
canBulkResolve: boolean
}>()
const emit = defineEmits<{
select: [riskFlagId: number]
page: [page: number]
toggleBulk: [riskFlagId: number]
selectVisibleLow: []
clearBulk: []
'update:bulkReviewNote': [value: string]
bulkDecide: [status: 'resolved' | 'dismissed']
}>()
</script>
<template>
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-slate-900">Offene Queue</h2>
<p class="text-sm text-slate-500">Nach Severity und Alter prüfen.</p>
</div>
<span class="rounded-2xl border border-violet-100 bg-violet-50 px-3 py-1.5 text-sm font-semibold text-violet-800">
{{ pageLabel }}
</span>
</div>
<div class="rounded-2xl border border-violet-100 bg-white/85 p-3">
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Bulk Low Severity</p>
<p class="mt-1 text-sm text-slate-600">
{{ selectedBulkRiskFlagIds.length }} ausgewählt · nur offene Low-Hinweise
</p>
</div>
<div class="flex flex-1 flex-col gap-2 sm:flex-row lg:max-w-2xl">
<input
:value="bulkReviewNote"
class="min-h-10 flex-1 rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
placeholder="Bulk-Notiz für niedrige Severity"
@input="emit('update:bulkReviewNote', ($event.target as HTMLInputElement).value)"
>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" class="gap-2 border border-violet-100" @click="emit('selectVisibleLow')">
<ListChecks class="h-4 w-4" />
Seite
</Button>
<Button size="sm" variant="ghost" class="gap-2 border border-violet-100" @click="emit('clearBulk')">
<X class="h-4 w-4" />
Leeren
</Button>
<Button size="sm" variant="secondary" class="gap-2" :disabled="!canBulkResolve || bulkSaving" @click="emit('bulkDecide', 'dismissed')">
<Trash2 class="h-4 w-4" />
Verwerfen
</Button>
<Button size="sm" class="gap-2" :disabled="!canBulkResolve || bulkSaving" @click="emit('bulkDecide', 'resolved')">
<CheckCircle2 class="h-4 w-4" />
Erledigt
</Button>
</div>
</div>
</div>
</div>
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
<article
v-for="flag in riskFlags"
:key="flag.id"
class="w-full rounded-2xl border p-4 transition"
:class="selectedRiskFlagId === flag.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
>
<div class="flex items-start gap-3">
<label
class="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-violet-100 bg-white"
:class="flag.severity.toLowerCase() === 'low' ? 'cursor-pointer' : 'opacity-35'"
:title="flag.severity.toLowerCase() === 'low' ? 'Für Bulk auswählen' : 'Bulk ist nur für Low-Severity aktiv'"
@click.stop
>
<input
type="checkbox"
class="h-4 w-4 accent-violet-600"
:disabled="flag.severity.toLowerCase() !== 'low'"
:checked="selectedBulkRiskFlagIds.includes(flag.id)"
@change="emit('toggleBulk', flag.id)"
>
</label>
<div class="min-w-0">
<button type="button" class="block w-full text-left" @click="emit('select', flag.id)">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold" :class="riskSeverityClass(flag.severity)">
<AlertTriangle class="h-3.5 w-3.5" />
{{ flag.severity }}
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2.5 py-1 text-xs font-semibold text-slate-600">
<Clock3 class="h-3.5 w-3.5" />
{{ riskAgeLabel(flag) }}
</span>
</div>
<h3 class="mt-3 line-clamp-2 text-base font-semibold text-slate-900">{{ flag.summary }}</h3>
<p class="mt-2 truncate text-sm text-slate-500">
{{ flag.source }} · {{ flag.type }} · {{ flag.twitchUserId || 'unbekannter User' }}
</p>
</button>
</div>
<span class="shrink-0 rounded-xl bg-white px-2.5 py-1 text-xs font-semibold text-slate-500">
ID {{ flag.id }}
</span>
</div>
</article>
<p v-if="totalOpen === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Risikohinweise vorhanden.
</p>
<p v-else-if="riskFlags.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Hinweise passen zum aktuellen Filter.
</p>
</div>
<div class="flex items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-white/75 px-3 py-2">
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-100"
:disabled="!hasPrevious"
@click="emit('page', page - 1)"
>
<ChevronLeft class="h-4 w-4" />
Zurück
</Button>
<span class="text-sm font-semibold text-slate-500">{{ pageLabel }}</span>
<Button
variant="ghost"
size="sm"
class="gap-2 border border-violet-100"
:disabled="!hasMore"
@click="emit('page', page + 1)"
>
Weiter
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</section>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { Save, SlidersHorizontal } from '@lucide/vue'
import type { AdminRiskRule } from '../../types/awards'
import Button from '../ui/Button.vue'
defineProps<{
rules: AdminRiskRule[]
loading: boolean
saving: boolean
}>()
const emit = defineEmits<{
updateRule: [ruleKey: string, patch: Partial<AdminRiskRule>]
save: []
}>()
</script>
<template>
<section class="border-t border-violet-100 bg-white p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="flex items-center gap-2">
<SlidersHorizontal class="h-5 w-5 text-violet-700" />
<h2 class="text-lg font-bold text-slate-900">Regel-Editor</h2>
</div>
<p class="mt-1 text-sm leading-6 text-slate-500">
Thresholds, Zeitfenster und Severity ohne Code-Deploy anpassen.
</p>
</div>
<Button class="gap-2" :disabled="loading || saving || rules.length === 0" @click="emit('save')">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Regeln speichern' }}
</Button>
</div>
<div v-if="loading" class="mt-5 rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Risk-Regeln werden geladen.
</div>
<div v-else class="mt-5 grid gap-3">
<article
v-for="rule in rules"
:key="rule.key"
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
>
<div class="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_120px_140px_150px_110px] lg:items-end">
<div class="min-w-0">
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
</div>
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Threshold</span>
<input
:value="rule.threshold"
type="number"
min="1"
max="500"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@input="emit('updateRule', rule.key, { threshold: Number(($event.target as HTMLInputElement).value) })"
>
</label>
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Fenster Min.</span>
<input
:value="rule.windowMinutes"
type="number"
min="1"
max="1440"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@input="emit('updateRule', rule.key, { windowMinutes: Number(($event.target as HTMLInputElement).value) })"
>
</label>
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Severity</span>
<select
:value="rule.severity"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@change="emit('updateRule', rule.key, { severity: ($event.target as HTMLSelectElement).value })"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<label class="flex h-10 items-center gap-2 rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700">
<input
type="checkbox"
class="h-4 w-4 accent-violet-600"
:checked="rule.enabled"
@change="emit('updateRule', rule.key, { enabled: ($event.target as HTMLInputElement).checked })"
>
Aktiv
</label>
</div>
</article>
<p v-if="rules.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Risk-Regeln verfügbar.
</p>
</div>
</section>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminSeasonCopyOption, AdminSeasonCreateForm } from './adminSeasonTypes'
const props = defineProps<{
open: boolean
createForm: AdminSeasonCreateForm
creating: boolean
canCreate: boolean
copySourceOptions: AdminSeasonCopyOption[]
createPublicReadinessIssues: string[]
phasePresets: string[]
onClose: () => void
onCreate: () => Promise<void> | void
}>()
</script>
<template>
<Modal :open="props.open" title="Neues Award-Jahr anlegen" @close="props.onClose">
<div class="grid gap-4 sm:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
<input v-model.number="props.createForm.year" type="number" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input v-model="props.createForm.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input v-model="props.createForm.showStreamUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Vorjahr kopieren</span>
<NativeSelect v-model="props.createForm.copyStructureFromSeasonId" :options="props.copySourceOptions" />
<span class="block text-sm leading-5 text-slate-500">
Kopiert Kategorien, Gruppen, Slugs, Beschreibung, Sortierung und Nominierungs-Limits. Kandidaten und Gewinner bleiben leer.
</span>
</label>
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
<button
v-for="phase in props.phasePresets"
:key="phase"
type="button"
class="rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition"
:class="props.createForm.currentPhase === phase ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="props.createForm.currentPhase = phase"
>
{{ phase }}
</button>
</fieldset>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierung startet</span>
<input v-model="props.createForm.nominationStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierung endet</span>
<input v-model="props.createForm.nominationEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Voting startet</span>
<input v-model="props.createForm.votingStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Voting endet</span>
<input v-model="props.createForm.votingEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review startet</span>
<input v-model="props.createForm.reviewStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review endet</span>
<input v-model="props.createForm.reviewEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Show-Datum</span>
<input v-model="props.createForm.showDate" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
<label class="space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Stream startet um</span>
<input v-model="props.createForm.showStartsAt" type="time" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label>
</div>
<div class="mt-5 space-y-3">
<label class="flex cursor-pointer gap-4 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<input v-model="props.createForm.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span class="text-sm leading-6 text-slate-600">
Community-only aktivieren
</span>
</label>
<label class="flex cursor-pointer gap-4 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<input v-model="props.createForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span class="text-sm leading-6 text-slate-600">
Dieses Jahr direkt als Public-Kontext markieren
</span>
</label>
<div v-if="props.createPublicReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
<strong class="block text-rose-800">Direkte Public-Aktivierung blockiert.</strong>
{{ props.createPublicReadinessIssues.join(' ') }}
</div>
</div>
<template #footer>
<Button variant="ghost" @click="props.onClose">Abbrechen</Button>
<Button :disabled="props.creating || !props.canCreate" @click="props.onCreate">
{{ props.creating ? 'Legt an ...' : 'Award-Jahr anlegen' }}
</Button>
</template>
</Modal>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { AlertTriangle } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Modal from '../ui/Modal.vue'
interface SeasonDeleteTarget {
id: number
year: number
name: string
isCurrent: boolean
}
const props = defineProps<{
seasonToDelete: SeasonDeleteTarget | null
deleting: boolean
onClose: () => void
onConfirmDelete: () => Promise<void> | void
}>()
</script>
<template>
<Modal :open="!!props.seasonToDelete" title="Award-Jahr löschen" @close="props.onClose">
<div v-if="props.seasonToDelete" class="space-y-4">
<div class="flex gap-4 rounded-[24px] border border-rose-100 bg-rose-50 p-4 text-rose-800">
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
<div>
<p class="font-semibold">Diese Aktion löscht das komplette Award-Jahr {{ props.seasonToDelete.year }}.</p>
<p class="mt-2 text-sm leading-6">
Dazu gehören Kategorien, Kandidaten, Nominierungen, Votes, Gewinner, Clips und zugehörige Risiko-Hinweise dieser Season.
Das lässt sich nicht rückgängig machen.
</p>
</div>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white/80 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Ausgewählt</p>
<p class="mt-2 font-semibold text-slate-900">{{ props.seasonToDelete.name }}</p>
</div>
<p v-if="props.seasonToDelete.isCurrent" class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Dieses Jahr ist öffentlich aktiv und kann nicht gelöscht werden.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="props.onClose">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="props.deleting || !props.seasonToDelete || props.seasonToDelete.isCurrent" @click="props.onConfirmDelete">
{{ props.deleting ? 'Löscht ...' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</template>
@@ -0,0 +1,143 @@
<template>
<Card class="overflow-hidden">
<div class="flex flex-col gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phasenwechsel</p>
<h2 class="mt-1 truncate text-xl font-bold leading-tight text-slate-900 lg:text-2xl">
{{ props.form.name || props.seasonName || 'Kein Award-Jahr gewählt' }}
</h2>
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
Wechsle die öffentliche Award-Phase bewusst. Die Datumsanalyse hilft beim Abgleich, speichert aber nichts automatisch.
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Aktiv</span>
<span class="rounded-full border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
{{ props.form.currentPhase || 'Nicht gesetzt' }}
</span>
</div>
</div>
<div v-if="autoMismatch" class="flex flex-col gap-3 border-b border-amber-100 bg-amber-50/70 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-amber-100 text-amber-700">
<AlertTriangle class="h-4 w-4" />
</span>
<div>
<p class="text-sm font-bold text-amber-900">Zeitplan empfiehlt {{ autoPhase }}</p>
<p class="mt-1 text-sm leading-6 text-amber-800/80">
Laut heutigen Daten liegt das Jahr in einer anderen Phase als gespeichert.
</p>
</div>
</div>
<Button
variant="ghost"
class="border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
:disabled="props.saving || !props.selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
@click="autoPhase && !autoPhaseIsCompleted && props.activatePhase(autoPhase)"
>
{{ autoPhaseIsCompleted ? 'Über Beenden abschließen' : 'Automatisch aktivieren' }}
</Button>
</div>
<div class="p-4">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<article
v-for="phase in phaseCards"
:key="phase.key"
class="flex min-h-[178px] flex-col rounded-[20px] border p-4 transition"
:class="phase.cardClass"
>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="h-2.5 w-2.5 rounded-full" :class="phase.dotClass"></span>
<span class="text-xs font-bold text-slate-400">{{ phase.ordinal }}</span>
</div>
<span class="rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-[0.08em]" :class="phase.badgeClass">
{{ phase.statusLabel }}
</span>
</div>
<h3 class="text-base font-bold leading-snug text-slate-900">{{ phase.title }}</h3>
<p class="mt-1 flex-1 text-xs leading-5 text-slate-500">{{ phase.dateRange }}</p>
<button
type="button"
class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
:class="phase.active ? 'bg-violet-600 text-white' : phase.finalLocked ? 'border border-slate-200 bg-slate-50 text-slate-500' : 'border border-violet-100 bg-white text-violet-700 hover:bg-violet-50'"
:disabled="props.saving || !props.selectedSeasonId || phase.active || phase.finalLocked"
:title="phase.actionTitle"
@click="props.activatePhase(phase.title)"
>
<CheckCircle2 v-if="phase.active" class="h-4 w-4" />
<LockKeyhole v-else-if="phase.finalLocked" class="h-4 w-4" />
<WandSparkles v-else class="h-4 w-4" />
{{ phase.actionLabel }}
</button>
</article>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { AlertTriangle, CheckCircle2, LockKeyhole, WandSparkles } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import {
createSeasonTimelineRows,
hasShowDayPassed,
normalizePhaseKey,
resolveAutoPhase,
type SeasonTimelineForm,
} from './adminSeasonTimeline'
const props = defineProps<{
form: SeasonTimelineForm & { name?: string }
seasonName: string
saving: boolean
selectedSeasonId: number | null
activatePhase: (phase: string) => Promise<void> | void
}>()
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(props.form))
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
const phaseCards = computed(() =>
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, null).map((phase, index) => {
const isAutoActive = autoPhase.value === phase.title && !phase.active
const isDone = phase.statusLabel === 'Erledigt'
const isCompletedPhase = phase.key === 'completed'
const finalLocked = isCompletedPhase && !phase.active
const showHasPassed = hasShowDayPassed(props.form)
return {
...phase,
ordinal: String(index + 1).padStart(2, '0'),
finalLocked,
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
actionTitle: isCompletedPhase
? showHasPassed
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
: 'Abschluss ist erst nach der Award Show möglich.'
: `Phase ${phase.title} aktivieren`,
cardClass: phase.active
? 'border-violet-200 bg-violet-50 shadow-[0_18px_42px_rgba(139,108,219,0.14)]'
: isAutoActive
? 'border-amber-200 bg-amber-50/70'
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/40',
dotClass: phase.active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : isDone ? 'bg-emerald-500' : 'bg-slate-300',
badgeClass: phase.active
? 'border-violet-200 bg-violet-100 text-violet-800'
: isAutoActive
? 'border-amber-200 bg-white text-amber-700'
: isDone
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500',
}
}),
)
</script>
@@ -0,0 +1,161 @@
<template>
<Card class="flex h-[684px] flex-col overflow-hidden">
<div class="shrink-0 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ seasonName || 'Kein Jahr gewählt' }}</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Die wichtigsten Stammdaten, der Public-Kontext und der finale Stream-Link auf einen Blick.
</p>
</div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="form.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
{{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div>
</div>
</div>
<div class="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 min-[1400px]:grid-cols-[minmax(0,1fr)_minmax(300px,0.78fr)]">
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
<input v-model.number="form.year" type="number" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="2027" :disabled="!selectedSeasonId || saving" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input v-model="form.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="VTuber Star Awards 2027" :disabled="!selectedSeasonId || saving" />
</label>
<label class="block space-y-2 sm:col-span-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input v-model="form.showStreamUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="https://twitch.tv/jayuhime" :disabled="!selectedSeasonId || saving" />
</label>
</div>
<div class="grid gap-3 md:grid-cols-2">
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || saving" />
<span>
<span class="block font-semibold text-slate-800">Community-only</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">Voting und Teilnahme laufen über die Community.</span>
</span>
</label>
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || saving || (!form.isCurrent && !canActivatePublic)" />
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ canActivatePublic || form.isCurrent ? 'Nur ein Jahr sollte öffentlich sichtbar sein.' : 'Erst die Readiness-Blocker unten loesen.' }}
</span>
</span>
</label>
</div>
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" role="status">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">{{ adminError }}</p>
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-3">
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
<Trash2 class="h-4 w-4" />
Löschen
</Button>
<Button variant="ghost" class="w-full gap-2 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="completing || !selectedSeasonId || !canCompleteSelectedSeason" @click="completeSeason">
<CheckCircle2 class="h-4 w-4" />
{{ completing ? 'Schliesst ...' : 'Beenden' }}
</Button>
<Button class="w-full" :disabled="saving || !selectedSeasonId" @click="saveSeason">
{{ saving ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
<p v-if="selectedSeasonIsCurrent" class="text-xs leading-5 text-slate-500">
Das öffentliche aktive Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich.
</p>
</div>
<aside class="min-h-0 space-y-4 lg:max-h-[432px] lg:overflow-y-auto lg:pr-1">
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
<div class="flex items-start gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<History class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Letzte Admin-Aktion</p>
<p class="mt-1 text-sm font-semibold leading-5 text-slate-800">
{{ loadingSeasonAudit ? 'Laedt Audit ...' : latestSeasonAuditSummary }}
</p>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ latestSeasonAuditMeta }}</p>
</div>
</div>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Public-Readiness</p>
<p class="mt-1 text-sm leading-5 text-slate-500">Blocker für Public auf einen Blick.</p>
</div>
<span class="shrink-0 rounded-full border px-3 py-1 text-xs font-semibold" :class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'">
{{ canActivatePublic ? 'Bereit' : 'Blockiert' }}
</span>
</div>
<div class="mt-4 grid gap-2">
<RouterLink
v-for="item in readinessItems"
:key="item.label"
:to="item.to"
class="flex min-h-[58px] items-start gap-3 rounded-2xl border px-3 py-2.5 transition hover:bg-violet-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
:class="item.complete ? 'border-emerald-100 bg-emerald-50/60' : item.blocking ? 'border-rose-200 bg-rose-50/70' : 'border-amber-100 bg-amber-50/60'"
>
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-700' : 'text-amber-700'" />
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-800">
{{ item.label }}
<span v-if="item.blocking && !item.complete" class="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] text-rose-700">Blocker</span>
</span>
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
</span>
</RouterLink>
</div>
</div>
<div v-if="archiveReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
<strong class="block text-rose-800">Abschluss noch blockiert.</strong>
{{ archiveReadinessIssues.join(' ') }}
</div>
</aside>
</div>
</Card>
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, History, Trash2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
form: AdminSeasonForm
seasonName: string
saving: boolean
completing: boolean
adminMessage: string
adminError: string
readinessItems: AdminSeasonReadinessItem[]
archiveReadinessIssues: string[]
canActivatePublic: boolean
loadingSeasonAudit: boolean
latestSeasonAuditSummary: string
latestSeasonAuditMeta: string
selectedSeasonId: number | null
canDeleteSelectedSeason: boolean
canCompleteSelectedSeason: boolean
selectedSeasonIsCurrent: boolean
openDeleteSeasonModal: () => void
saveSeason: () => Promise<boolean | void> | boolean | void
completeSeason: () => Promise<void>
}>()
</script>
@@ -0,0 +1,175 @@
<template>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen bearbeiten</h2>
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Änderungen werden direkt im gewählten Award-Jahr gespeichert.
</p>
</div>
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
{{ props.form.currentPhase || 'Keine Phase gesetzt' }}
</span>
</div>
</div>
<p v-if="timelineError" class="border-b border-rose-100 bg-rose-50 px-6 py-3 text-sm font-semibold text-rose-700" role="alert">{{ timelineError }}</p>
<div class="hidden grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] gap-4 border-b border-violet-100 bg-violet-50/50 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<span></span>
<span>Phase</span>
<span>Zeitraum</span>
<span>Status</span>
<span class="text-right">Bearbeiten</span>
</div>
<div class="max-h-[390px] overflow-y-auto divide-y divide-violet-50">
<div
v-for="row in timelineRows"
:key="row.key"
class="grid gap-4 px-5 py-3.5 lg:grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] lg:items-center"
:class="row.isEditing ? 'bg-violet-50/50' : 'bg-white/70'"
>
<span class="mt-2 h-2.5 w-2.5 rounded-full lg:mx-auto lg:mt-0" :class="row.dotClass"></span>
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ row.title }}</p>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ row.description }}</p>
</div>
<div v-if="row.isEditing" class="grid gap-2">
<template v-if="row.key === 'show'">
<input v-model="draft.start" type="date" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
<input v-model="draft.showStartsAt" type="time" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</template>
<template v-else>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
<input v-model="draft.start" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
<input v-model="draft.end" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</div>
</template>
</div>
<p v-else class="text-sm font-semibold text-slate-600">{{ row.dateRange }}</p>
<span class="w-fit rounded-full border px-3 py-1 text-xs font-bold" :class="row.statusClass">
{{ row.statusLabel }}
</span>
<div class="flex flex-wrap justify-start gap-2 lg:justify-end">
<template v-if="row.isEditing">
<Button size="sm" :disabled="props.saving" @click="saveTimelineEdit(row)">OK</Button>
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 transition hover:bg-violet-50"
:disabled="props.saving"
title="Bearbeitung abbrechen"
@click="cancelTimelineEdit"
>
<X class="h-4 w-4" />
</button>
</template>
<template v-else>
<button
v-if="row.editable"
type="button"
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700 transition hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="props.saving || !props.selectedSeasonId"
title="Zeitraum bearbeiten"
@click="startTimelineEdit(row)"
>
<Pencil class="h-4 w-4" />
</button>
<span v-else class="text-xs font-semibold text-slate-400">fest</span>
</template>
</div>
</div>
</div>
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
<p class="text-xs leading-5 text-slate-500">
Hinweis: Die Phasen sind als Systemphasen fest verdrahtet, damit Nominierung, Voting, Review und Show sicher mit Public-API und Rate-Limits zusammenspielen.
</p>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { Pencil, X } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import {
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
type PhaseKey,
type PhaseRowConfig,
type SeasonTimelineForm,
} from './adminSeasonTimeline'
const props = defineProps<{
form: SeasonTimelineForm
saving: boolean
selectedSeasonId: number | null
saveSeason: () => Promise<boolean | void> | boolean | void
}>()
const editingPhase = ref<PhaseKey | null>(null)
const timelineError = ref('')
const draft = reactive({
start: '',
end: '',
showStartsAt: '20:00',
})
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(props.form))
const timelineRows = computed(() =>
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, editingPhase.value),
)
function startTimelineEdit(row: PhaseRowConfig) {
if (!row.editable) return
timelineError.value = ''
editingPhase.value = row.key
draft.start = row.start ? props.form[row.start] : ''
draft.end = row.end ? props.form[row.end] : ''
draft.showStartsAt = props.form.showStartsAt || '20:00'
}
function cancelTimelineEdit() {
editingPhase.value = null
timelineError.value = ''
}
async function saveTimelineEdit(row: PhaseRowConfig) {
timelineError.value = ''
if (!row.start || !row.end) {
return
}
if (!draft.start || !draft.end) {
timelineError.value = 'Bitte fülle Start und Ende der Phase aus.'
return
}
if (row.key !== 'show' && draft.start > draft.end) {
timelineError.value = 'Das Startdatum darf nicht nach dem Enddatum liegen.'
return
}
props.form[row.start] = draft.start
props.form[row.end] = row.key === 'show' ? draft.start : draft.end
if (row.key === 'show') {
props.form.showStartsAt = draft.showStartsAt || '20:00'
}
const saved = await props.saveSeason()
if (saved !== false) {
editingPhase.value = null
}
}
</script>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import Select from 'primevue/select'
import { CalendarCog, CheckCircle2, Clock3, Sparkles, Tags, Users } from '@lucide/vue'
import NativeSelect from '../ui/NativeSelect.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
@@ -61,12 +61,9 @@ const yearStats = computed(() => [
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Jahr wechseln</label>
<Select
<NativeSelect
v-model="selectedSeasonId"
:options="seasonOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
</div>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, Database, Loader2, RefreshCw } from '@lucide/vue'
import type { DatabaseHealthResponse } from '../../types/awards'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
defineProps<{
healthLoading: boolean
healthError: string
databaseHealth: DatabaseHealthResponse
pendingMigrationCount: number
healthLoadedLabel: string
refreshDatabaseHealth: () => Promise<void> | void
}>()
</script>
<template>
<Card class="p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-start gap-3">
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="databaseHealth.canConnect ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
<Database class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Live-Systemstatus</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Datenbank-Healthcheck</h2>
<p class="mt-1 text-sm text-slate-500">Zuletzt geprüft: {{ healthLoadedLabel }}</p>
</div>
</div>
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" :disabled="healthLoading" @click="refreshDatabaseHealth">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': healthLoading }" />
{{ healthLoading ? 'Prüft ...' : 'Aktualisieren' }}
</Button>
</div>
<p v-if="healthError" class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ healthError }}</p>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider</p>
<strong class="mt-1 block text-sm text-slate-900">{{ databaseHealth.provider }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Connection</p>
<strong class="mt-1 block text-sm text-slate-900">{{ databaseHealth.configuredConnection.source }}</strong>
</div>
<div class="rounded-2xl border px-4 py-3" :class="pendingMigrationCount > 0 ? 'border-amber-100 bg-amber-50/70' : 'border-emerald-100 bg-emerald-50/60'">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Migrationen</p>
<div class="mt-1 flex items-center gap-2">
<Loader2 v-if="healthLoading" class="h-4 w-4 animate-spin text-violet-600" />
<AlertTriangle v-else-if="pendingMigrationCount > 0" class="h-4 w-4 text-amber-700" />
<CheckCircle2 v-else class="h-4 w-4 text-emerald-700" />
<strong class="block text-sm text-slate-900">{{ pendingMigrationCount > 0 ? `${pendingMigrationCount} offen` : 'Aktuell' }}</strong>
</div>
</div>
</div>
<div v-if="pendingMigrationCount > 0" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<p class="font-semibold">Ausstehende Migrationen</p>
<p class="mt-1">{{ databaseHealth.pendingMigrations.join(', ') }}</p>
</div>
</Card>
</template>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2, CircleDashed, ExternalLink } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminSettingsCheckItem, AdminSettingsGateItem, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
import Card from '../ui/Card.vue'
const props = defineProps<{
checks: AdminSettingsCheckItem[]
gates: AdminSettingsGateItem[]
contentChecks: AdminSettingsCheckItem[]
contentCompletion: number
}>()
const readyChecks = computed(() => props.checks.filter((check) => check.value).length)
const activeGates = computed(() => props.gates.filter((gate) => gate.state).length)
const incompleteContentCount = computed(() => props.contentChecks.length - props.contentCompletion)
const summaryItems = computed<AdminSettingsStatusSummary[]>(() => [
{
label: 'Systemchecks',
value: `${readyChecks.value}/${props.checks.length}`,
note: readyChecks.value === props.checks.length ? 'Alles grün.' : 'Mindestens ein Check braucht Aufmerksamkeit.',
tone: readyChecks.value === props.checks.length ? 'good' : 'warning',
},
{
label: 'Betriebszustände',
value: `${activeGates.value}/${props.gates.length}`,
note: 'Aktive Phasen und offene Moderationspunkte.',
tone: activeGates.value > 0 ? 'neutral' : 'warning',
},
{
label: 'Landingpage',
value: `${props.contentCompletion}/${props.contentChecks.length}`,
note: incompleteContentCount.value === 0 ? 'Public-Inhalte vollständig.' : `${incompleteContentCount.value} Bereiche fehlen.`,
tone: incompleteContentCount.value === 0 ? 'good' : 'warning',
},
])
function toneClasses(tone: AdminSettingsTone) {
if (tone === 'good') {
return {
panel: 'border-emerald-100 bg-emerald-50/55',
text: 'text-emerald-700',
icon: 'bg-emerald-100 text-emerald-700',
}
}
if (tone === 'warning') {
return {
panel: 'border-amber-100 bg-amber-50/65',
text: 'text-amber-700',
icon: 'bg-amber-100 text-amber-700',
}
}
if (tone === 'danger') {
return {
panel: 'border-rose-100 bg-rose-50/65',
text: 'text-rose-700',
icon: 'bg-rose-100 text-rose-700',
}
}
return {
panel: 'border-violet-100 bg-violet-50/55',
text: 'text-violet-700',
icon: 'bg-violet-100 text-violet-700',
}
}
function booleanTone(value: boolean): AdminSettingsTone {
return value ? 'good' : 'warning'
}
</script>
<template>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/50 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Betriebsübersicht</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Alles Wichtige auf einen Blick</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
Systemchecks, aktive Phasen und Landingpage-Readiness stehen jetzt zusammen, ohne dieselben Inhalte noch einmal editierbar zu duplizieren.
</p>
</div>
<RouterLink to="/admin/content" class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl bg-violet-600 px-5 text-sm font-semibold !text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500" style="color:#fff;">
<ExternalLink class="h-4 w-4 !text-white" />
Content Hub
</RouterLink>
</div>
</div>
<div class="grid gap-3 p-5 md:grid-cols-3">
<div
v-for="item in summaryItems"
:key="item.label"
class="rounded-2xl border px-4 py-3"
:class="toneClasses(item.tone).panel"
>
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</p>
<strong class="mt-1 block text-2xl" :class="toneClasses(item.tone).text">{{ item.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-600">{{ item.note }}</p>
</div>
</div>
<div class="grid border-t border-violet-100 lg:grid-cols-3">
<section class="border-b border-violet-100 p-5 lg:border-b-0 lg:border-r">
<h3 class="text-sm font-bold text-slate-900">Systemchecks</h3>
<div class="mt-4 space-y-3">
<component
:is="check.to ? RouterLink : 'div'"
v-for="check in checks"
:key="check.label"
:to="check.to ?? undefined"
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition"
:class="[toneClasses(booleanTone(check.value)).panel, check.to ? 'hover:bg-violet-50/60' : '']"
>
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(booleanTone(check.value)).icon">
<component :is="check.icon" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-slate-900">{{ check.label }}</p>
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ check.note }}</p>
</div>
</component>
</div>
</section>
<section class="border-b border-violet-100 p-5 lg:border-b-0 lg:border-r">
<h3 class="text-sm font-bold text-slate-900">Betriebszustände</h3>
<div class="mt-4 space-y-3">
<RouterLink
v-for="gate in gates"
:key="gate.label"
:to="gate.to"
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:bg-violet-50/60"
:class="toneClasses(gate.state ? 'good' : 'neutral').panel"
>
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(gate.state ? 'good' : 'neutral').icon">
<CheckCircle2 v-if="gate.state" class="h-4.5 w-4.5" />
<CircleDashed v-else class="h-4.5 w-4.5" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-slate-900">{{ gate.label }}</p>
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ gate.note }}</p>
</div>
</RouterLink>
</div>
</section>
<section class="p-5">
<h3 class="text-sm font-bold text-slate-900">Landingpage-Readiness</h3>
<div class="mt-4 space-y-3">
<RouterLink
v-for="check in contentChecks"
:key="check.label"
to="/admin/content"
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:bg-violet-50/60"
:class="toneClasses(booleanTone(check.value)).panel"
>
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(booleanTone(check.value)).icon">
<component :is="check.icon" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-slate-900">{{ check.label }}</p>
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ check.note }}</p>
</div>
</RouterLink>
</div>
</section>
</div>
</Card>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: boolean
label: string
activeLabel?: string
inactiveLabel?: string
disabled?: boolean
tone?: 'violet' | 'amber'
}>(), {
activeLabel: 'Aktiv',
inactiveLabel: 'Inaktiv',
disabled: false,
tone: 'violet',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function toggle() {
if (!props.disabled) {
emit('update:modelValue', !props.modelValue)
}
}
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="label"
:disabled="disabled"
class="inline-flex items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 disabled:cursor-not-allowed disabled:opacity-60"
:class="tone === 'amber' && modelValue ? 'border-amber-200 bg-white/75 text-amber-800' : 'border-violet-100 bg-violet-50/60 text-violet-800'"
@click="toggle"
>
<span>{{ modelValue ? activeLabel : inactiveLabel }}</span>
<span
class="relative h-7 w-12 rounded-full transition"
:class="modelValue ? tone === 'amber' ? 'bg-amber-500' : 'bg-violet-600' : 'bg-slate-200'"
>
<span class="absolute left-1 top-1 h-5 w-5 rounded-full bg-white shadow transition" :class="{ 'translate-x-5': modelValue }"></span>
</span>
</button>
</template>
@@ -0,0 +1,26 @@
export type SocialLinkForm = {
label: string
platform: string
icon: string
url: string
showOnHost: boolean
showOnCommunity: boolean
}
export type FaqFormItem = {
question: string
answer: string
}
export type AdminContentForm = {
hostDisplayName: string
hostTagline: string
newsletterUrl: string
privacyEmail: string
privacyPolicyContent: string
imprintUrl: string
contactUrl: string
sponsorsUrl: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -0,0 +1,38 @@
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
export interface ReviewFormState {
displayName: string
channelSlug: string
platform: string
reviewNote: string
}
export interface ReviewStatItem {
label: string
value: number
}
export interface ReviewCategoryOption {
id: number
label: string
count: number
}
export interface ReviewNominationCollision extends AdminCandidateItem {}
export interface ReviewManagerState {
reviewSaving: number | null
adminMessage: string
adminError: string
reviewFilter: string
categoryFilter: number | null
selectedNominationId: number | null
reviewForms: Record<number, ReviewFormState>
filteredNominations: AdminNominationReviewItem[]
selectedNomination: AdminNominationReviewItem | null
reviewStats: ReviewStatItem[]
reviewedNominations: AdminNominationReviewItem[]
categoryOptions: ReviewCategoryOption[]
selectedCandidateCollision: ReviewNominationCollision | null
canApproveSelected: boolean
}
@@ -0,0 +1,184 @@
export type PhaseKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type SeasonDateField =
| 'nominationStartsAt'
| 'nominationEndsAt'
| 'votingStartsAt'
| 'votingEndsAt'
| 'reviewStartsAt'
| 'reviewEndsAt'
| 'showDate'
export interface SeasonTimelineForm {
year: number
currentPhase: string
nominationStartsAt: string
nominationEndsAt: string
votingStartsAt: string
votingEndsAt: string
reviewStartsAt: string
reviewEndsAt: string
showDate: string
showStartsAt: string
}
export interface PhaseRowConfig {
key: PhaseKey
title: string
description: string
start: SeasonDateField | null
end: SeasonDateField | null
editable: boolean
}
export interface SeasonTimelineRow extends PhaseRowConfig {
active: boolean
isEditing: boolean
dateRange: string
statusLabel: string
dotClass: string
statusClass: string
}
export const SEASON_PHASES: PhaseRowConfig[] = [
{
key: 'nomination',
title: 'Nominierung',
description: 'Community schlägt Creator und Clips vor.',
start: 'nominationStartsAt',
end: 'nominationEndsAt',
editable: true,
},
{
key: 'voting',
title: 'Community Voting',
description: 'Finale Kandidat:innen sind sichtbar und wählbar.',
start: 'votingStartsAt',
end: 'votingEndsAt',
editable: true,
},
{
key: 'review',
title: 'Review & Auswertung',
description: 'Team prüft Votes, Clips und Ergebnisse.',
start: 'reviewStartsAt',
end: 'reviewEndsAt',
editable: true,
},
{
key: 'show',
title: 'Award Show',
description: 'Finale Live-Show inklusive Stream-Countdown.',
start: 'showDate',
end: 'showDate',
editable: true,
},
{
key: 'completed',
title: 'Abgeschlossen',
description: 'Award-Jahr ist archiviert und Public-Aktionen sind gesperrt.',
start: null,
end: null,
editable: false,
},
]
export function createSeasonTimelineRows(
form: SeasonTimelineForm,
currentPhaseKey: PhaseKey | string,
autoPhase: string,
editingPhase: PhaseKey | null,
): SeasonTimelineRow[] {
return SEASON_PHASES.map((phase) => {
const active = phase.key === currentPhaseKey
const isAutoActive = autoPhase === phase.title
const state = active ? 'active' : resolvePhaseState(form, phase)
return {
...phase,
active,
isEditing: editingPhase === phase.key,
dateRange: formatPhaseRange(form, phase),
statusLabel: active ? 'Aktiv' : isAutoActive ? 'Auto' : state === 'done' ? 'Erledigt' : 'Geplant',
dotClass: active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : state === 'done' ? 'bg-emerald-500' : 'bg-slate-300',
statusClass: active
? 'border-violet-200 bg-violet-100 text-violet-800'
: isAutoActive
? 'border-amber-200 bg-amber-50 text-amber-700'
: state === 'done'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500',
}
})
}
export function normalizePhaseKey(value: string): PhaseKey | string {
const phase = value.trim().toLowerCase()
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
if (phase.includes('show')) return 'show'
if (phase.includes('review') || phase.includes('auswert')) return 'review'
if (phase.includes('vot')) return 'voting'
if (phase.includes('nomin')) return 'nomination'
return phase
}
export function resolveAutoPhase(form: SeasonTimelineForm) {
const today = startOfDay(new Date())
for (const phase of SEASON_PHASES.slice(0, 4)) {
const start = phase.start ? parseDate(form[phase.start]) : null
const end = phase.end ? parseDate(form[phase.end]) : null
if (start && end && start <= today && today <= end) {
return phase.title
}
}
return hasShowDayPassed(form) ? 'Abgeschlossen' : ''
}
function resolvePhaseState(form: SeasonTimelineForm, phase: PhaseRowConfig) {
const today = startOfDay(new Date())
if (phase.key === 'completed') {
return hasShowDayPassed(form) ? 'done' : 'upcoming'
}
const end = phase.end ? parseDate(form[phase.end]) : null
return end && end < today ? 'done' : 'upcoming'
}
function formatPhaseRange(form: SeasonTimelineForm, phase: PhaseRowConfig) {
if (phase.key === 'completed') {
return form.showDate ? `nach ${formatDate(form.showDate)}` : 'nach der Show'
}
const start = phase.start ? form[phase.start] : ''
const end = phase.end ? form[phase.end] : ''
if (!start && !end) return 'Noch nicht terminiert'
if (phase.key === 'show') return `${formatDate(start)} · ${form.showStartsAt || '20:00'} Uhr`
if (start === end) return formatDate(start)
return `${formatDate(start)} - ${formatDate(end)}`
}
function parseDate(value: string) {
if (!value) return null
const date = startOfDay(new Date(`${value}T00:00:00`))
return Number.isNaN(date.getTime()) ? null : date
}
export function hasShowDayPassed(form: Pick<SeasonTimelineForm, 'showDate'>) {
const showDate = parseDate(form.showDate)
return Boolean(showDate && startOfDay(new Date()) > showDate)
}
function startOfDay(date: Date) {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next
}
function formatDate(value: string) {
if (!value) return 'offen'
const date = parseDate(value)
return date
? date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: value
}
@@ -0,0 +1,33 @@
export interface AdminSeasonForm {
year: number
name: string
showStreamUrl: string
currentPhase: string
isCurrent: boolean
isCommunityOnly: boolean
nominationStartsAt: string
nominationEndsAt: string
votingStartsAt: string
votingEndsAt: string
reviewStartsAt: string
reviewEndsAt: string
showDate: string
showStartsAt: string
}
export interface AdminSeasonCreateForm extends AdminSeasonForm {
copyStructureFromSeasonId: number | null
}
export interface AdminSeasonReadinessItem {
label: string
note: string
complete: boolean
blocking: boolean
to: string
}
export interface AdminSeasonCopyOption {
label: string
value: number | null
}
@@ -0,0 +1,35 @@
import type { Component } from 'vue'
export type AdminSettingsTone = 'good' | 'warning' | 'danger' | 'neutral'
export interface AdminOperationalSettingsForm {
demoLoginEnabled: boolean
demoLoginIdentifier: string
demoLoginTwitchUserId: string
demoLoginDisplayName: string
maintenanceModeEnabled: boolean
maintenanceTitle: string
maintenanceMessage: string
}
export interface AdminSettingsCheckItem {
label: string
value: boolean
note: string
icon: Component
to: string | null
}
export interface AdminSettingsGateItem {
label: string
state: boolean
note: string
to: string
}
export interface AdminSettingsStatusSummary {
label: string
value: string
note: string
tone: AdminSettingsTone
}
@@ -0,0 +1,153 @@
import { computed } from 'vue'
import { AlertTriangle, CheckCircle2, Clock3, Sparkles, Tags, Trophy, Users, Vote } from '@lucide/vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
export function useAdminAnalyticsManager() {
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const topCategories = computed(() => store.admin.topCategories)
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.votes])))
const categoryHealth = computed(() =>
seasonDetail.value.categories
.map((category) => {
const candidates = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
const reviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
const hasWinner = resultMap.value.has(category.id)
const votes = voteMap.value.get(category.name) ?? 0
const status = candidates === 0 ? 'Leer' : reviews > 0 ? 'Review offen' : hasWinner ? 'Gewinner gesetzt' : 'Bereit'
return {
id: category.id,
name: category.name,
groupName: category.groupName,
candidates,
reviews,
hasWinner,
votes,
status,
statusClass: candidates === 0
? 'border-rose-100 bg-rose-50 text-rose-700'
: reviews > 0
? 'border-amber-100 bg-amber-50 text-amber-700'
: hasWinner
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-sky-100 bg-sky-50 text-sky-700',
}
})
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
)
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const winnerCoveragePct = computed(() => {
const categoryCount = seasonDetail.value.categories.length
if (categoryCount === 0) return 0
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
})
const metricCards = computed(() => [
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
{ label: 'Stimmen', value: totalVotes.value, note: 'gezählte Votes', icon: Vote, tone: 'text-violet-700 bg-violet-50 border-violet-100' },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'freigegeben in Kategorien', icon: Users, tone: 'text-cyan-700 bg-cyan-50 border-cyan-100' },
{ label: 'Review-Backlog', value: seasonDetail.value.pendingNominations.length, note: 'offene Entscheidungen', icon: Clock3, tone: 'text-amber-700 bg-amber-50 border-amber-100' },
])
const readinessCards = computed(() => [
{
label: 'Kategorie-Abdeckung',
value: `${seasonDetail.value.categories.length - emptyCategories.value.length}/${seasonDetail.value.categories.length}`,
note: emptyCategories.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${emptyCategories.value.length} Kategorien sind noch leer.`,
icon: Tags,
to: emptyCategories.value.length > 0 ? '/admin/candidates' : '/admin/categories',
},
{
label: 'Gewinnerstatus',
value: `${winnerCoveragePct.value}%`,
note: `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`,
icon: Trophy,
to: '/admin/winners',
},
{
label: 'Freigabe-Risiko',
value: String(categoriesWithReviews.value.length + pendingClipCount.value),
note: `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`,
icon: AlertTriangle,
to: categoriesWithReviews.value.length > 0 ? '/admin/nominations?review=1' : '/admin/clips',
},
])
const insightCards = computed(() => {
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
const busiestReviewCategory = categoriesWithReviews.value[0]
const strongestCategory = topCategories.value[0]
return [
{
label: 'Votes pro Kandidat',
value: votesPerCandidate.toLocaleString('de-DE'),
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
icon: Vote,
},
{
label: 'Staerkste Kategorie',
value: strongestCategory?.votes.toLocaleString('de-DE') ?? '0',
note: strongestCategory ? strongestCategory.category : 'Noch keine Vote-Verteilung vorhanden.',
icon: CheckCircle2,
},
{
label: 'Review-Hotspot',
value: String(busiestReviewCategory?.reviews ?? 0),
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine offenen Review-Hotspots.',
icon: Clock3,
},
]
})
const attentionItems = computed(() => [
{
key: 'empty-categories',
label: 'Leere Kategorien',
value: emptyCategories.value.length,
note: emptyCategories.value.length === 0 ? 'Keine Luecke in der Kandidatenbasis.' : emptyCategories.value.slice(0, 3).map((category) => category.name).join(', '),
to: '/admin/candidates',
tone: emptyCategories.value.length > 0 ? 'border-rose-100 bg-rose-50 text-rose-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
key: 'pending-reviews',
label: 'Offene Reviews',
value: seasonDetail.value.pendingNominations.length,
note: categoriesWithReviews.value.length === 0 ? 'Review-Queue ist leer.' : `${categoriesWithReviews.value.length} Kategorien betroffen.`,
to: '/admin/nominations?review=1',
tone: seasonDetail.value.pendingNominations.length > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
key: 'missing-winners',
label: 'Gewinner fehlen',
value: categoriesWithoutWinner.value.length,
note: categoriesWithoutWinner.value.length === 0 ? 'Alle Gewinner sind gesetzt.' : 'Finalisierung auf der Gewinner-Seite abschliessen.',
to: '/admin/winners',
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
])
return {
categoryHealth,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
maxVotes,
winnerCoveragePct,
}
}
@@ -0,0 +1,633 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
const auditPageLimit = 80
const allFilter = 'all'
const actionLabels: Record<string, string> = {
'candidate.create': 'Kandidat angelegt',
'candidate.delete': 'Kandidat gelöscht',
'candidate.update': 'Kandidat aktualisiert',
'category.create': 'Kategorie angelegt',
'category.delete': 'Kategorie gelöscht',
'category.update': 'Kategorie aktualisiert',
'clip.delete': 'Clip gelöscht',
'clip.status.update': 'Clip-Status geändert',
'nomination.approve': 'Nominierung übernommen',
'nomination.reject': 'Nominierung verworfen',
'operational-settings.update': 'Betriebseinstellungen geändert',
'result.delete': 'Winner entfernt',
'result.set': 'Winner gesetzt',
'risk.resolve': 'Risk Flag entschieden',
'season.create': 'Jahr angelegt',
'season.delete': 'Jahr gelöscht',
'season.phase.update': 'Phase geändert',
'season.public.update': 'Public-Kontext geändert',
'season.update': 'Jahr aktualisiert',
'seed.initialize': 'Seed-Daten initialisiert',
'site-settings.update': 'Seiteneinstellungen geändert',
}
const groupLabels: Record<string, string> = {
candidate: 'Kandidaten',
category: 'Kategorien',
clip: 'Clips',
nomination: 'Reviews',
result: 'Gewinner',
risk: 'Risiko',
seed: 'System',
season: 'Jahre',
site: 'Seite',
'site-settings': 'Seite',
'operational-settings': 'Betrieb',
}
const relatedRoutes: Record<string, { label: string; to: string }> = {
candidate: { label: 'Kandidaten öffnen', to: '/admin/candidates' },
category: { label: 'Kategorien öffnen', to: '/admin/categories' },
clip: { label: 'Clips öffnen', to: '/admin/clips' },
nomination: { label: 'Review-Fokus öffnen', to: '/admin/nominations?review=1' },
result: { label: 'Gewinner öffnen', to: '/admin/winners' },
'risk-flag': { label: 'Risiko öffnen', to: '/admin/risk' },
season: { label: 'Jahre öffnen', to: '/admin/years' },
'site-settings': { label: 'Landingpage öffnen', to: '/admin/content' },
'operational-settings': { label: 'Einstellungen öffnen', to: '/admin/settings' },
}
export interface AuditStat {
label: string
value: string
note: string
}
export interface AuditCountItem {
key: string
label: string
count: number
}
export interface AuditFocusCard {
label: string
value: string
note: string
}
export interface AuditMetadataItem {
key: string
value: string
}
export interface AuditChangeItem {
field: string
label: string
from: string
to: string
sensitive: boolean
}
export interface AuditRelatedLink {
label: string
to: string
}
export interface AuditFilterPreset {
key: string
label: string
description: string
filters: {
query?: string
action?: string
entityType?: string
}
}
export interface AuditEntityOption {
value: string
label: string
}
export interface AuditLogRow extends AdminAuditEntry {
actionLabel: string
actionGroup: string
actionToneClass: string
dotClass: string
entityLabel: string
createdLabel: string
ageLabel: string
metadataItems: AuditMetadataItem[]
changeItems: AuditChangeItem[]
requestContextItems: AuditMetadataItem[]
relatedLink: AuditRelatedLink | null
rawMetadataJson: string
}
const entityOptions: AuditEntityOption[] = [
{ value: allFilter, label: 'Alle Objekte' },
{ value: 'season', label: 'Jahre' },
{ value: 'category', label: 'Kategorien' },
{ value: 'candidate', label: 'Kandidaten' },
{ value: 'nomination', label: 'Reviews' },
{ value: 'clip', label: 'Clips' },
{ value: 'risk-flag', label: 'Risiko' },
{ value: 'result', label: 'Gewinner' },
{ value: 'site-settings', label: 'Landingpage' },
{ value: 'operational-settings', label: 'Betrieb' },
{ value: 'seed', label: 'System' },
]
const filterPresets: AuditFilterPreset[] = [
{
key: 'risk-security',
label: 'Risk & Security',
description: 'Entscheidungen mit Sicherheits- oder Missbrauchskontext.',
filters: { action: 'risk.resolve' },
},
{
key: 'public-context',
label: 'Public-Kontext',
description: 'Jahreswechsel, Phasen und sichtbare Public-Änderungen.',
filters: { entityType: 'season' },
},
{
key: 'content-privacy',
label: 'Content & Privacy',
description: 'Landingpage, Datenschutz und Content-Konfiguration.',
filters: { action: 'site-settings.update' },
},
{
key: 'destructive',
label: 'Löschungen',
description: 'Entfernende Aktionen aus Kategorien, Kandidaten, Clips und Jahren.',
filters: { query: 'delete' },
},
]
function countBy<T>(items: T[], getKey: (item: T) => string) {
const counts = new Map<string, number>()
for (const item of items) {
const key = getKey(item).trim() || 'unbekannt'
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()]
.map(([key, count]) => ({ key, label: key, count }))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
}
function humanizeAction(action: string) {
if (actionLabels[action]) return actionLabels[action]
return action
.split('.')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).replaceAll('_', ' '))
.join(' ')
}
function getActionGroup(action: string) {
const group = action.split('.')[0] ?? ''
return groupLabels[group] ?? 'System'
}
function getActionToneClass(action: string) {
if (action.startsWith('risk')) return 'border-rose-100 bg-rose-50 text-rose-700'
if (action.startsWith('clip') || action.startsWith('nomination')) return 'border-amber-100 bg-amber-50 text-amber-700'
if (action.startsWith('result')) return 'border-emerald-100 bg-emerald-50 text-emerald-700'
if (action.startsWith('season')) return 'border-sky-100 bg-sky-50 text-sky-700'
if (action.startsWith('site') || action.startsWith('operational')) return 'border-indigo-100 bg-indigo-50 text-indigo-700'
if (action.includes('delete')) return 'border-slate-200 bg-slate-100 text-slate-700'
return 'border-cyan-100 bg-cyan-50 text-cyan-700'
}
function getDotClass(action: string) {
if (action.startsWith('risk')) return 'bg-rose-400'
if (action.startsWith('clip') || action.startsWith('nomination')) return 'bg-amber-400'
if (action.startsWith('result')) return 'bg-emerald-400'
if (action.startsWith('season')) return 'bg-sky-400'
if (action.startsWith('site') || action.startsWith('operational')) return 'bg-indigo-400'
if (action.includes('delete')) return 'bg-slate-400'
return 'bg-cyan-400'
}
function stringifyMetadataValue(entryValue: unknown) {
if (typeof entryValue === 'object') return JSON.stringify(entryValue)
return String(entryValue)
}
function parseMetadata(metadataJson: string | undefined) {
if (!metadataJson) return []
try {
const value = JSON.parse(metadataJson) as Record<string, unknown>
if (!value || typeof value !== 'object' || Array.isArray(value)) return []
return Object.entries(value)
.filter(([key]) => key !== 'changes' && key !== 'Changes')
.filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== '')
.map(([key, entryValue]) => ({
key,
value: stringifyMetadataValue(entryValue),
}))
} catch {
return [{ key: 'metadata', value: metadataJson }]
}
}
function parseChangeItems(metadataJson: string | undefined): AuditChangeItem[] {
if (!metadataJson) return []
try {
const value = JSON.parse(metadataJson) as Record<string, unknown>
const changes = value.changes ?? value.Changes
if (!Array.isArray(changes)) return []
return changes
.map((change) => {
if (!change || typeof change !== 'object' || Array.isArray(change)) return null
const item = change as Record<string, unknown>
return {
field: String(item.field ?? item.Field ?? ''),
label: String(item.label ?? item.Label ?? item.field ?? item.Field ?? 'Änderung'),
from: String(item.from ?? item.From ?? ''),
to: String(item.to ?? item.To ?? ''),
sensitive: Boolean(item.sensitive ?? item.Sensitive),
}
})
.filter((change): change is AuditChangeItem => Boolean(change?.field || change?.label))
} catch {
return []
}
}
function formatDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
}
function formatAge(value: string) {
const timestamp = new Date(value).getTime()
if (Number.isNaN(timestamp)) return 'Zeitpunkt unbekannt'
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000))
if (minutes < 2) return 'gerade eben'
if (minutes < 60) return `vor ${minutes} Min.`
const hours = Math.round(minutes / 60)
if (hours < 48) return `vor ${hours} Std.`
return `vor ${Math.round(hours / 24)} Tagen`
}
function escapeCsvCell(value: unknown) {
const text = String(value ?? '')
if (!/[",\n\r;]/.test(text)) return text
return `"${text.replaceAll('"', '""')}"`
}
function getDateBoundary(value: string, isEndOfDay: boolean) {
if (!value) return undefined
const suffix = isEndOfDay ? 'T23:59:59.999' : 'T00:00:00.000'
const date = new Date(`${value}${suffix}`)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
function buildRequestContextItems(entry: AdminAuditEntry) {
return [
{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' },
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
]
}
function buildRelatedLink(entry: AdminAuditEntry) {
const route = relatedRoutes[entry.entityType]
if (!route) return null
if (entry.entityType === 'nomination' && entry.entityId) {
return { label: route.label, to: `${route.to}?nominationId=${encodeURIComponent(entry.entityId)}` }
}
return route
}
function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
return {
...entry,
actionLabel: humanizeAction(entry.actionType),
actionGroup: getActionGroup(entry.actionType),
actionToneClass: getActionToneClass(entry.actionType),
dotClass: getDotClass(entry.actionType),
entityLabel: `${entry.entityType} ${entry.entityId}`.trim(),
createdLabel: formatDate(entry.createdAt),
ageLabel: formatAge(entry.createdAt),
metadataItems: parseMetadata(entry.metadataJson),
changeItems: parseChangeItems(entry.metadataJson),
requestContextItems: buildRequestContextItems(entry),
relatedLink: buildRelatedLink(entry),
rawMetadataJson: entry.metadataJson || '{}',
}
}
function downloadCsv(entries: AdminAuditEntry[]) {
const rows = [
[
'Id',
'Admin',
'Aktion',
'Objekt-Typ',
'Objekt-Id',
'Zusammenfassung',
'Zeitpunkt',
'IP',
'User-Agent',
'Metadaten',
],
...entries.map((entry) => [
entry.id,
entry.adminTwitchUserId,
entry.actionType,
entry.entityType,
entry.entityId,
entry.summary,
new Date(entry.createdAt).toISOString(),
entry.createdFromIp,
entry.userAgent,
entry.metadataJson,
]),
]
const csv = rows.map((row) => row.map(escapeCsvCell).join(';')).join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `vtuber-star-awards-audit-${new Date().toISOString().slice(0, 10)}.csv`
document.body.append(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
export function useAdminAuditManager() {
const store = useAwardsStore()
const query = ref('')
const selectedAdmin = ref(allFilter)
const selectedAction = ref(allFilter)
const entityFilter = ref(allFilter)
const fromDate = ref('')
const toDate = ref('')
const loadingAudit = ref(false)
const loadingMore = ref(false)
const auditError = ref('')
const exportMessage = ref('')
const lastLoadedAt = ref<Date | null>(null)
const auditEntries = ref<AdminAuditEntry[]>([])
const totalCount = ref(0)
const nextCursor = ref<string | null>(null)
const selectedEntry = ref<AuditLogRow | null>(null)
const appliedPresetKey = ref('')
let searchTimer: ReturnType<typeof window.setTimeout> | null = null
let exportMessageTimer: ReturnType<typeof window.setTimeout> | null = null
let suppressFilterWatch = false
const adminCounts = computed(() => countBy(auditEntries.value, (entry) => entry.adminTwitchUserId))
const actionCounts = computed(() =>
countBy(auditEntries.value, (entry) => entry.actionType).map((item) => ({
...item,
label: humanizeAction(item.key),
})),
)
const entityCounts = computed(() =>
countBy(auditEntries.value, (entry) => entry.entityType).map((item) => ({
...item,
label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key,
})),
)
const auditRows = computed<AuditLogRow[]>(() => auditEntries.value.map(createAuditRow))
const metadataCount = computed(() => auditEntries.value.filter((entry) => parseMetadata(entry.metadataJson).length > 0).length)
const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.createdFromIp || entry.userAgent).length)
const recentDayCount = computed(() => {
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length
})
const hasMore = computed(() => Boolean(nextCursor.value))
const loadedCountLabel = computed(() => auditEntries.value.length.toLocaleString('de-DE'))
const totalCountLabel = computed(() => totalCount.value.toLocaleString('de-DE'))
const logStats = computed<AuditStat[]>(() => [
{ label: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` },
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
{ label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' },
{ label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: 'mit IP oder User-Agent' },
])
const focusCards = computed<AuditFocusCard[]>(() => {
const topAdmin = adminCounts.value[0]
const topAction = actionCounts.value[0]
const topEntity = entityCounts.value[0]
return [
{
label: 'Top Admin',
value: topAdmin?.label ?? 'Keine Daten',
note: topAdmin ? `${topAdmin.count} Aktionen in den geladenen Treffern` : 'Noch kein Admin aktiv',
},
{
label: 'Top Aktion',
value: topAction?.label ?? 'Keine Daten',
note: topAction ? `${topAction.count} Einträge` : 'Noch kein Aktionstyp vorhanden',
},
{
label: 'Objekt-Fokus',
value: topEntity?.label ?? 'Keine Daten',
note: topEntity ? `${topEntity.count} Einträge` : 'Noch kein Objekttyp vorhanden',
},
{
label: 'Details',
value: metadataCount.value.toLocaleString('de-DE'),
note: 'Einträge mit sichtbaren Metadaten',
},
]
})
const activeFilterCount = computed(() =>
(query.value.trim() ? 1 : 0) +
(selectedAdmin.value !== allFilter ? 1 : 0) +
(selectedAction.value !== allFilter ? 1 : 0) +
(entityFilter.value !== allFilter ? 1 : 0) +
(fromDate.value ? 1 : 0) +
(toDate.value ? 1 : 0),
)
const lastLoadedLabel = computed(() =>
lastLoadedAt.value ? lastLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
)
const emptyStateText = computed(() =>
activeFilterCount.value > 0
? 'Keine Log-Einträge passen zu den aktiven Filtern.'
: 'Noch keine Audit-Einträge vorhanden.',
)
const pageSummaryLabel = computed(() =>
`${loadedCountLabel.value} von ${totalCountLabel.value} Treffern geladen`,
)
function buildAuditOptions(cursor: string | null = null): AdminAuditQueryOptions {
return {
limit: auditPageLimit,
query: query.value,
admin: selectedAdmin.value === allFilter ? undefined : selectedAdmin.value,
action: selectedAction.value === allFilter ? undefined : selectedAction.value,
entityType: entityFilter.value === allFilter ? undefined : entityFilter.value,
from: getDateBoundary(fromDate.value, false),
to: getDateBoundary(toDate.value, true),
cursor,
}
}
function clearTimers() {
if (searchTimer) {
window.clearTimeout(searchTimer)
searchTimer = null
}
}
function scheduleLoad() {
clearTimers()
searchTimer = window.setTimeout(() => {
void loadAuditEntries()
}, 350)
}
async function loadAuditEntries(options: { append?: boolean } = {}) {
const append = options.append ?? false
if (append && !nextCursor.value) return
if (append) {
loadingMore.value = true
} else {
loadingAudit.value = true
selectedEntry.value = null
}
auditError.value = ''
try {
const response = await store.loadAdminAuditEntriesPage(
buildAuditOptions(append ? nextCursor.value : null),
append,
)
auditEntries.value = append ? [...auditEntries.value, ...response.items] : response.items
totalCount.value = response.totalCount
nextCursor.value = response.nextCursor
lastLoadedAt.value = new Date()
} catch (error) {
auditError.value = error instanceof Error ? error.message : 'Audit-Logs konnten nicht geladen werden.'
} finally {
loadingAudit.value = false
loadingMore.value = false
}
}
async function loadNextPage() {
await loadAuditEntries({ append: true })
}
function exportAuditCsv() {
if (auditEntries.value.length === 0) return
downloadCsv(auditEntries.value)
exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.`
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
exportMessageTimer = window.setTimeout(() => {
exportMessage.value = ''
}, 3500)
}
async function clearFilters() {
clearTimers()
suppressFilterWatch = true
query.value = ''
selectedAdmin.value = allFilter
selectedAction.value = allFilter
entityFilter.value = allFilter
fromDate.value = ''
toDate.value = ''
appliedPresetKey.value = ''
suppressFilterWatch = false
await loadAuditEntries()
}
async function applyPreset(presetKey: string) {
const preset = filterPresets.find((item) => item.key === presetKey)
if (!preset) return
clearTimers()
suppressFilterWatch = true
query.value = preset.filters.query ?? ''
selectedAdmin.value = allFilter
selectedAction.value = preset.filters.action ?? allFilter
entityFilter.value = preset.filters.entityType ?? allFilter
fromDate.value = ''
toDate.value = ''
appliedPresetKey.value = preset.key
suppressFilterWatch = false
await loadAuditEntries()
}
function openAuditEntry(entry: AuditLogRow) {
selectedEntry.value = entry
}
function closeAuditEntry() {
selectedEntry.value = null
}
watch([query, selectedAdmin, selectedAction, entityFilter, fromDate, toDate], () => {
if (suppressFilterWatch) return
appliedPresetKey.value = ''
scheduleLoad()
})
onMounted(() => {
auditEntries.value = store.admin.auditEntries
totalCount.value = store.admin.auditEntries.length
void loadAuditEntries()
})
onBeforeUnmount(() => {
clearTimers()
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
})
return {
query,
selectedAdmin,
selectedAction,
entityFilter,
fromDate,
toDate,
loadingAudit,
loadingMore,
auditError,
exportMessage,
selectedEntry,
auditRows,
adminCounts,
actionCounts,
entityCounts,
logStats,
focusCards,
activeFilterCount,
lastLoadedLabel,
emptyStateText,
pageSummaryLabel,
hasMore,
filterPresets,
appliedPresetKey,
entityOptions,
loadAuditEntries,
loadNextPage,
exportAuditCsv,
clearFilters,
applyPreset,
openAuditEntry,
closeAuditEntry,
}
}
@@ -0,0 +1,223 @@
import { computed, reactive, ref, watch } from 'vue'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
interface CandidateForm {
categoryId: number
displayName: string
channelSlug: string
platform: string
}
const pageSize = 10
export function useAdminCandidateManager() {
const store = useAwardsStore()
const saving = ref(false)
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const search = ref('')
const categoryFilter = ref<number | null>(null)
const page = ref(1)
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const candidateToDelete = ref<AdminCandidateItem | null>(null)
const form = reactive<CandidateForm>({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
)
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
const categoryLabelMap = computed<Record<number, string>>(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
)
const duplicateCandidateKeys = computed(() => {
const counts = new Map<string, number>()
for (const candidate of seasonDetail.value.candidates) {
const nameKey = createCandidateDuplicateKey(candidate, 'name')
const slugKey = createCandidateDuplicateKey(candidate, 'slug')
counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1)
if (candidate.channelSlug.trim()) {
counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1)
}
}
return counts
})
const duplicateCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
if (categoryFilter.value) {
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
}
if (query) {
list = list.filter((candidate) =>
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
.join(' ')
.toLowerCase()
.includes(query),
)
}
return list
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
const pagedCandidates = computed(() => {
const start = (page.value - 1) * pageSize
return filteredCandidates.value.slice(start, start + pageSize)
})
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim()),
)
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) {
page.value = max
}
})
function clearFilters() {
search.value = ''
categoryFilter.value = null
}
function openCreate() {
adminMessage.value = ''
adminError.value = ''
editingId.value = 'new'
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
modalOpen.value = true
}
function openEdit(candidate: AdminCandidateItem) {
adminMessage.value = ''
adminError.value = ''
editingId.value = candidate.id
form.categoryId = candidate.categoryId
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
modalOpen.value = true
}
function handlePlatformSelection(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'custom') {
if (socialIconOptionForValue(form.platform)) {
form.platform = ''
}
return
}
const option = socialIconOptionForValue(value)
form.platform = option?.label ?? value
}
async function saveModal() {
if (!canSave.value || !selectedSeasonId.value) {
return
}
saving.value = true
adminError.value = ''
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) {
return
}
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `${candidateToDelete.value.displayName}" wurde gelöscht.`
candidateToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
return {
seasonDetail,
saving,
deleting,
adminMessage,
adminError,
search,
categoryFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
duplicateCandidateCount,
filteredCandidates,
pagedCandidates,
totalPages,
rangeStart,
rangeEnd,
modalOpen,
form,
modalTitle,
canSave,
candidatePlatformOptions,
selectedPlatformValue,
candidateToDelete,
clearFilters,
openCreate,
openEdit,
handlePlatformSelection,
saveModal,
confirmDelete,
}
}
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
}
function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys: Map<string, number>) {
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1
}
@@ -0,0 +1,190 @@
import { computed, reactive, ref, watch } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCategoryItem } from '../../types/awards'
type CategoryStatusFilter = 'all' | 'empty' | 'reviews' | 'thin'
interface CategoryForm {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}
export function useAdminCategoryManager() {
const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail)
const query = ref('')
const statusFilter = ref<CategoryStatusFilter>('all')
const selectedCategoryId = ref<number | null>(null)
const saving = ref<number | 'new' | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const categoryToDelete = ref<AdminCategoryItem | null>(null)
const deleting = ref(false)
const editForms = reactive<Record<number, CategoryForm>>({})
const newCategoryForm = reactive(createEmptyCategoryForm())
const categoriesWithState = computed(() =>
seasonDetail.value.categories
.map((category) => ({
...category,
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
)
const filteredCategories = computed(() => {
const search = query.value.trim().toLowerCase()
return categoriesWithState.value.filter((category) => {
const matchesStatus =
statusFilter.value === 'all' ||
(statusFilter.value === 'empty' && category.candidates === 0) ||
(statusFilter.value === 'reviews' && category.pending > 0) ||
(statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser))
const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description]
.join(' ')
.toLowerCase()
.includes(search)
return matchesStatus && matchesSearch
})
})
const selectedCategory = computed(() =>
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
)
const categoryStats = computed(() => [
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
{ key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
])
watch(
seasonDetail,
(detail) => {
for (const category of detail.categories) {
editForms[category.id] = createCategoryForm(category)
}
newCategoryForm.sortOrder = detail.categories.length + 1
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = detail.categories[0]?.id ?? null
}
},
{ immediate: true },
)
watch(filteredCategories, (categories) => {
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = categories[0]?.id ?? null
}
})
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
saving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
saving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
saving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Kategorie angelegt.'
Object.assign(newCategoryForm, createEmptyCategoryForm(seasonDetail.value.categories.length + 1))
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
saving.value = null
}
}
function fillNewSlug() {
newCategoryForm.slug = slugify(newCategoryForm.name)
}
async function confirmDeleteCategory() {
if (!categoryToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
categoryToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
return {
selectedSeasonId,
query,
statusFilter,
selectedCategoryId,
saving,
adminMessage,
adminError,
editForms,
newCategoryForm,
filteredCategories,
selectedCategory,
categoryStats,
statusFilters,
categoryToDelete,
deleting,
saveCategory,
createCategory,
fillNewSlug,
confirmDeleteCategory,
}
}
function createCategoryForm(category: AdminCategoryItem): CategoryForm
function createCategoryForm(category?: AdminCategoryItem, sortOrder?: number): CategoryForm
function createCategoryForm(category?: AdminCategoryItem, sortOrder = 1): CategoryForm {
return {
groupName: category?.groupName ?? '',
name: category?.name ?? '',
slug: category?.slug ?? '',
description: category?.description ?? '',
sortOrder: category?.sortOrder ?? sortOrder,
maxNomineesPerUser: category?.maxNomineesPerUser ?? 3,
}
}
function createEmptyCategoryForm(sortOrder = 1): CategoryForm {
return createCategoryForm(undefined, sortOrder)
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
@@ -0,0 +1,198 @@
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Film, Layers3, PlayCircle } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
type ClipReviewStatus = 'pending' | 'approved' | 'rejected'
type ClipStatusFilter = 'all' | 'pending' | 'reviewed'
type ClipPlatformFilter = 'all' | string
export function useAdminClipManager() {
const store = useAwardsStore()
const query = ref('')
const statusFilter = ref<ClipStatusFilter>('all')
const platformFilter = ref<ClipPlatformFilter>('all')
const categoryFilter = ref('all')
const deleting = ref(false)
const statusSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const reviewNotes = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
)
const clips = computed(() => {
const search = query.value.trim().toLowerCase()
return submissions.value.filter((clip) =>
matchesStatusFilter(clip.status, statusFilter.value) &&
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const duplicateUrls = computed(() => {
const counts = new Map<string, number>()
for (const clip of submissions.value) {
const key = normalizeClipUrl(clip.clipUrl)
if (!key) continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
})
const creatorClipGroups = computed(() => {
const counts = new Map<string, number>()
for (const clip of submissions.value) {
const key = creatorClipKey(clip)
if (!key) continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
})
const stats = computed(() => [
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Freigegeben', value: submissions.value.filter((clip) => clip.status === 'approved').length, icon: CheckCircle2 },
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
])
const platformFilters = computed(() => [
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
.sort()
.map((platform) => ({
key: platform,
label: platform,
count: submissions.value.filter((clip) => clip.platform === platform).length,
})),
])
const categoryFilters = computed(() => [
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
...categories.value
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
.map((category) => ({
id: String(category.id),
label: category.name,
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
})),
])
watch(submissions, (items) => {
for (const clip of items) {
if (reviewNotes[clip.id] === undefined) {
reviewNotes[clip.id] = clip.reviewNote ?? ''
}
}
}, { immediate: true })
async function updateClipStatus(clip: AdminClipSubmissionItem, status: ClipReviewStatus) {
if (!selectedSeasonId.value) return
statusSaving.value = clip.id
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminClipStatus(clip.id, selectedSeasonId.value, {
status,
reviewNote: reviewNotes[clip.id]?.trim() || undefined,
})
adminMessage.value = `Clip ${clip.id} wurde auf „${statusLabel(status)}“ gesetzt.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Clip-Status konnte nicht gespeichert werden.'
} finally {
statusSaving.value = null
}
}
async function confirmDelete() {
if (!clipToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
clipToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
function duplicateUrlCount(clipUrl: string) {
return duplicateUrls.value.get(normalizeClipUrl(clipUrl)) ?? 0
}
function creatorClipCount(clip: AdminClipSubmissionItem) {
return creatorClipGroups.value.get(creatorClipKey(clip)) ?? 0
}
return {
query,
statusFilter,
platformFilter,
categoryFilter,
deleting,
statusSaving,
adminMessage,
adminError,
clipToDelete,
reviewNotes,
submissions,
categoryName,
clips,
stats,
statusFilters,
platformFilters,
categoryFilters,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
confirmDelete,
}
}
function matchesStatusFilter(status: string, filter: ClipStatusFilter) {
return filter === 'all' || status === filter || (filter === 'reviewed' && status !== 'pending')
}
function normalizeClipUrl(clipUrl: string) {
return clipUrl.trim().toLowerCase()
}
function creatorClipKey(clip: AdminClipSubmissionItem) {
if (clip.candidateId) return `candidate:${clip.candidateId}`
const creator = clip.creator.trim().toLowerCase()
const categoryId = clip.categoryId ?? 'global'
return creator ? `creator:${categoryId}:${creator}` : ''
}
function platformClass(platform: string) {
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
function statusClass(status: string) {
if (status === 'approved') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (status === 'rejected') return 'border-rose-200 bg-rose-50 text-rose-700'
return 'border-amber-200 bg-amber-50 text-amber-700'
}
function statusLabel(status: string) {
if (status === 'approved') return 'freigegeben'
if (status === 'rejected') return 'abgelehnt'
return 'offen'
}
@@ -0,0 +1,298 @@
import { computed, reactive, ref, watch } from 'vue'
import {
normalizeSocialIconKey,
simpleIconForKey,
socialIconOptionForKey,
} from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
function createEmptyForm(): AdminContentForm {
return {
hostDisplayName: '',
hostTagline: '',
newsletterUrl: '',
privacyEmail: '',
privacyPolicyContent: '',
imprintUrl: '',
contactUrl: '',
sponsorsUrl: '',
socialLinks: [],
faq: [],
}
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('Icon konnte nicht gelesen werden.'))
reader.readAsDataURL(file)
})
}
async function validateRasterDimensions(file: File) {
if (file.type === 'image/svg+xml') {
return
}
const objectUrl = URL.createObjectURL(file)
try {
await new Promise<void>((resolve, reject) => {
const image = new Image()
image.onload = () => {
if (image.naturalWidth > 512 || image.naturalHeight > 512) {
reject(new Error('Icon ist zu groß. Maximal erlaubt sind 512 x 512 px.'))
return
}
resolve()
}
image.onerror = () => reject(new Error('Icon-Abmessungen konnten nicht geprüft werden.'))
image.src = objectUrl
})
} finally {
URL.revokeObjectURL(objectUrl)
}
}
export function useAdminContentManager() {
const store = useAwardsStore()
const form = reactive(createEmptyForm())
const saving = ref(false)
const saveMessage = ref('')
const saveError = ref('')
const privacyPreviewOpen = ref(false)
const iconUploadError = ref('')
watch(
() => store.adminSiteSettings,
(settings) => {
form.hostDisplayName = settings.hostDisplayName
form.hostTagline = settings.hostTagline
form.newsletterUrl = settings.newsletterUrl
form.privacyEmail = settings.privacyEmail
form.privacyPolicyContent = settings.privacyPolicyContent
form.imprintUrl = settings.imprintUrl
form.contactUrl = settings.contactUrl
form.sponsorsUrl = settings.sponsorsUrl
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
icon: item.icon ?? '',
url: item.url ?? '',
showOnHost: item.showOnHost ?? true,
showOnCommunity: item.showOnCommunity ?? true,
}))
form.faq = settings.faq.map((item) => ({
question: item.question ?? '',
answer: item.answer ?? '',
}))
},
{ immediate: true, deep: true },
)
const privacyPreviewBlocks = computed(() =>
form.privacyPolicyContent
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyUpdatedLabel = computed(() => {
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
if (!updatedAt) {
return 'Noch keine Änderung gespeichert'
}
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(updatedAt))
})
function addSocialLink() {
form.socialLinks.push({
label: 'Neu',
platform: 'website',
icon: '',
url: '',
showOnHost: true,
showOnCommunity: true,
})
}
function removeSocialLink(index: number) {
form.socialLinks.splice(index, 1)
}
function addFaqItem() {
form.faq.push({ question: '', answer: '' })
}
function removeFaqItem(index: number) {
form.faq.splice(index, 1)
}
function isUploadedIcon(icon: string) {
return icon.startsWith('data:image/')
}
function socialIconKey(social: SocialLinkForm) {
return normalizeSocialIconKey(social.icon || social.platform)
}
function selectedSocialIconValue(social: SocialLinkForm) {
return socialIconOptionForKey(social.platform)
? normalizeSocialIconKey(social.platform)
: 'custom'
}
function handleSocialIconSelection(event: Event, index: number) {
const value = (event.target as HTMLSelectElement).value
const social = form.socialLinks[index]
if (!social) {
return
}
if (value === 'custom') {
if (socialIconOptionForKey(social.platform)) {
social.platform = ''
}
return
}
const option = socialIconOptionForKey(value)
social.platform = value
if (!social.label || social.label === 'Neu' || social.label === 'Website / Fallback') {
social.label = option?.label ?? value
}
if (social.icon && !isUploadedIcon(social.icon)) {
social.icon = ''
}
}
function hasSocialIconPreview(social: SocialLinkForm) {
return Boolean(social.label || social.platform || social.icon)
}
function socialIconModeLabel(social: SocialLinkForm) {
if (isUploadedIcon(social.icon)) {
return 'hochgeladenes Custom Icon'
}
const icon = simpleIconForKey(socialIconKey(social))
if (icon) {
return `eingebautes ${icon.title} Icon`
}
return 'generisches Fallback Icon'
}
function socialSimpleIconPath(social: SocialLinkForm) {
return simpleIconForKey(socialIconKey(social))?.path ?? ''
}
function socialSimpleIconColor(social: SocialLinkForm) {
return `#${simpleIconForKey(socialIconKey(social))?.hex ?? '8b5cf6'}`
}
async function handleSocialIconUpload(event: Event, index: number) {
iconUploadError.value = ''
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
iconUploadError.value = 'Bitte PNG, JPG, WebP oder SVG hochladen.'
return
}
const maxBytes = 256 * 1024
if (file.size > maxBytes) {
iconUploadError.value = 'Icon ist zu groß. Maximal erlaubt sind 256 KB.'
return
}
try {
await validateRasterDimensions(file)
form.socialLinks[index].icon = await readFileAsDataUrl(file)
} catch (error) {
iconUploadError.value = error instanceof Error ? error.message : 'Icon konnte nicht hochgeladen werden.'
}
}
function clearSocialIcon(index: number) {
form.socialLinks[index].icon = ''
}
async function saveSiteSettings(sectionLabel = 'Landingpage-Inhalte') {
saving.value = true
saveMessage.value = ''
saveError.value = ''
try {
await store.updateAdminSiteSettings({
hostDisplayName: form.hostDisplayName,
hostTagline: form.hostTagline,
newsletterUrl: form.newsletterUrl,
privacyEmail: form.privacyEmail,
privacyPolicyContent: form.privacyPolicyContent,
imprintUrl: form.imprintUrl,
contactUrl: form.contactUrl,
sponsorsUrl: form.sponsorsUrl,
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
platform: item.platform.trim(),
icon: item.icon.trim(),
url: item.url.trim(),
showOnHost: item.showOnHost,
showOnCommunity: item.showOnCommunity,
}))
.filter((item) => item.label && item.platform && item.url),
faq: form.faq
.map((item) => ({
question: item.question.trim(),
answer: item.answer.trim(),
}))
.filter((item) => item.question && item.answer),
})
saveMessage.value = `${sectionLabel} gespeichert und auf der Landingpage aktualisiert.`
} catch (error) {
saveError.value = error instanceof Error ? error.message : 'Inhalte konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
return {
form,
saving,
saveMessage,
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
addFaqItem,
removeFaqItem,
isUploadedIcon,
selectedSocialIconValue,
handleSocialIconSelection,
hasSocialIconPreview,
socialIconModeLabel,
socialSimpleIconPath,
socialSimpleIconColor,
handleSocialIconUpload,
clearSocialIcon,
saveSiteSettings,
adminSiteSettings: computed(() => store.adminSiteSettings),
}
}
@@ -0,0 +1,193 @@
import { computed } from 'vue'
import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
const metricToneMap = {
Nominierungen: {
icon: Sparkles,
source: 'Nomination-Tabelle',
},
Stimmen: {
icon: BarChart3,
source: 'VoteEntries-Tabelle',
},
Kategorien: {
icon: Tags,
source: 'Categories-Tabelle',
},
'Reviews offen': {
icon: Clock3,
source: 'Nominations-Review-Status',
},
Risikohinweise: {
icon: ShieldAlert,
source: 'RiskFlags-Tabelle',
},
} as const
export function useAdminDashboardOverview() {
const store = useAwardsStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const metricCards = computed(() =>
metrics.value.map((metric) => ({
...metric,
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
icon: BarChart3,
source: 'Backend-Metrik',
}),
})),
)
const openReviewCount = computed(() => store.adminSeasonDetail.pendingNominations.length)
const openRiskCount = computed(() => getRiskMetricValue(metrics.value))
const liveSummary = computed(() => {
const phase = store.adminSeasonDetail.currentPhase || 'nicht gesetzt'
const reviewText = openReviewCount.value === 1 ? '1 offene Review' : `${openReviewCount.value} offene Reviews`
const riskText = openRiskCount.value === 1 ? '1 Risikohinweis' : `${openRiskCount.value} Risikohinweise`
const categoryText = store.adminSeasonDetail.categories.length === 1
? '1 Kategorie'
: `${store.adminSeasonDetail.categories.length} Kategorien`
const candidateText = store.adminSeasonDetail.candidates.length === 1
? '1 Kandidat'
: `${store.adminSeasonDetail.candidates.length} Kandidaten`
return `Aktuelle Phase: ${phase}. ${categoryText}, ${candidateText}, ${reviewText} und ${riskText} kommen direkt aus der Admin-API.`
})
const liveStatusBadge = computed(() => {
if (openRiskCount.value > 0) {
return `${openRiskCount.value} Risiko offen`
}
if (openReviewCount.value > 0) {
return `${openReviewCount.value} Review offen`
}
return 'Betrieb wirkt sauber'
})
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
const yearTotals = computed(() => [
{
label: 'Nominierungen gesamt',
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
icon: Sparkles,
},
{
label: 'Stimmen gesamt',
value: getVoteMetricValue(metrics.value),
note: 'alle abgegebenen Votes',
icon: BarChart3,
},
{
label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length,
note: 'für Voting und Archiv gepflegt',
icon: Users,
},
{
label: 'Kategorien',
value: store.adminSeasonDetail.categories.length,
note: 'aktive Award-Kategorien',
icon: Tags,
},
{
label: 'Offene Reviews',
value: store.adminSeasonDetail.pendingNominations.length,
note: 'brauchen Team-Entscheidung',
icon: Clock3,
},
{
label: 'Risikohinweise',
value: openRiskCount.value,
note: 'aktuell offen',
icon: ShieldAlert,
},
])
const priorityActions = computed(() => [
{
label: 'Reviews bearbeiten',
value: store.adminSeasonDetail.pendingNominations.length,
to: '/admin/nominations?review=1',
hint: 'Freitext-Nominierungen warten auf Entscheidung',
icon: Sparkles,
tone: 'violet',
},
{
label: 'Risiko prüfen',
value: openRiskCount.value,
to: '/admin/risk',
hint: 'Auffällige Muster brauchen Sichtung',
icon: ShieldAlert,
tone: 'rose',
},
{
label: 'Kategorien pflegen',
value: store.adminSeasonDetail.categories.length,
to: '/admin/categories',
hint: 'Texte, Limits und Reihenfolge aktuell halten',
icon: Tags,
tone: 'amber',
},
{
label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length,
to: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users,
tone: 'emerald',
},
])
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
)
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
)
return [
{
label: 'Kategorien ohne Kandidaten',
value: categoriesWithoutCandidates.length,
to: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
},
{
label: 'Review-Backlog verteilt',
value: categoriesWithReviews.length,
to: '/admin/nominations',
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
},
{
label: 'Risk Flags offen',
value: openRiskCount.value,
to: '/admin/risk',
state: openRiskCount.value === 0 ? 'ok' : 'danger',
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
},
]
})
return {
store,
activities,
topCategories,
metricCards,
openReviewCount,
openRiskCount,
liveSummary,
liveStatusBadge,
maxCategoryVotes,
totalCategoryVotes,
yearTotals,
priorityActions,
operationChecks,
}
}
@@ -0,0 +1,220 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import { clearSiteStatusCache } from '../../lib/siteStatus'
import type { AdminOperationalSettingsResponse } from '../../types/awards'
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary } from './adminSettingsTypes'
const fallbackMaintenanceTitle = 'Sternenpause'
const fallbackMaintenanceMessage = 'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.'
export function useAdminOperationalSettings() {
const operationalLoading = ref(true)
const operationalSaving = ref(false)
const operationalError = ref('')
const operationalSuccess = ref('')
const demoPasswordSet = ref(false)
const demoManagedByDatabase = ref(false)
const demoPasswordInput = ref('')
const savedOperationalSnapshot = ref('')
const operationalForm = reactive<AdminOperationalSettingsForm>({
demoLoginEnabled: false,
demoLoginIdentifier: '',
demoLoginTwitchUserId: '',
demoLoginDisplayName: '',
maintenanceModeEnabled: false,
maintenanceTitle: fallbackMaintenanceTitle,
maintenanceMessage: fallbackMaintenanceMessage,
})
const demoPasswordHint = computed(() => {
if (demoPasswordInput.value.trim()) {
return 'Dieses neue Passwort wird beim Speichern gesetzt.'
}
if (demoPasswordSet.value) {
return demoManagedByDatabase.value
? 'Passwort ist gesetzt. Leer lassen, wenn es bleiben soll.'
: 'Aktuell kommt das Passwort aus der Demo-Config. Beim Speichern wird es in die DB übernommen.'
}
return 'Noch kein Demo-Passwort gesetzt. Zum Aktivieren bitte ein Passwort vergeben.'
})
const demoCredentialsComplete = computed(() =>
!operationalForm.demoLoginEnabled
|| (
Boolean(operationalForm.demoLoginIdentifier.trim())
&& Boolean(operationalForm.demoLoginTwitchUserId.trim())
&& Boolean(operationalForm.demoLoginDisplayName.trim())
&& (demoPasswordSet.value || Boolean(demoPasswordInput.value.trim()))
),
)
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
{
label: 'Demo Login',
value: operationalForm.demoLoginEnabled ? 'Aktiv' : 'Aus',
note: operationalForm.demoLoginEnabled
? demoCredentialsComplete.value
? 'Öffentlicher Zugriff ist geschützt.'
: 'Aktivierung braucht vollständige Zugangsdaten.'
: 'Landingpage ist öffentlich erreichbar.',
tone: operationalForm.demoLoginEnabled
? demoCredentialsComplete.value ? 'warning' : 'danger'
: 'good',
},
{
label: 'Wartungsmodus',
value: operationalForm.maintenanceModeEnabled ? 'Aktiv' : 'Aus',
note: operationalForm.maintenanceModeEnabled
? 'Besucher sehen die Wartungsseite.'
: 'Öffentliche Seiten werden normal ausgeliefert.',
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
},
{
label: 'Passwort',
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
note: demoManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration',
tone: demoPasswordSet.value ? 'good' : 'warning',
},
])
const hasUnsavedOperationalChanges = computed(() =>
Boolean(savedOperationalSnapshot.value) &&
buildOperationalSnapshot() !== savedOperationalSnapshot.value,
)
function applyOperationalSettings(response: AdminOperationalSettingsResponse) {
operationalForm.demoLoginEnabled = response.demoLoginEnabled
operationalForm.demoLoginIdentifier = response.demoLoginEmail
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
operationalForm.maintenanceTitle = response.maintenanceTitle
operationalForm.maintenanceMessage = response.maintenanceMessage
demoPasswordSet.value = response.demoLoginPasswordSet
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
demoPasswordInput.value = ''
rememberSavedOperationalSettings()
}
function buildOperationalSnapshot() {
return JSON.stringify({
demoLoginEnabled: operationalForm.demoLoginEnabled,
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
maintenanceTitle: operationalForm.maintenanceTitle,
maintenanceMessage: operationalForm.maintenanceMessage,
demoPasswordInput: demoPasswordInput.value,
})
}
function rememberSavedOperationalSettings() {
savedOperationalSnapshot.value = buildOperationalSnapshot()
}
async function loadOperationalSettings(options: { silent?: boolean } = {}) {
if (!options.silent) {
operationalLoading.value = true
operationalError.value = ''
}
try {
applyOperationalSettings(await api.getAdminOperationalSettings())
} catch (error) {
operationalError.value = error instanceof ApiRequestError
? error.message
: 'Operational Settings konnten nicht geladen werden.'
} finally {
if (!options.silent) {
operationalLoading.value = false
}
}
}
function validateOperationalSettings() {
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
return false
}
if (operationalForm.demoLoginEnabled && !demoCredentialsComplete.value) {
operationalError.value = 'Demo-Login braucht Login, Twitch-ID, Anzeigenamen und ein gesetztes Passwort.'
return false
}
return true
}
async function saveOperationalSettings() {
operationalError.value = ''
operationalSuccess.value = ''
if (!validateOperationalSettings()) {
return
}
operationalSaving.value = true
try {
const result = await api.updateAdminOperationalSettings({
...operationalForm,
demoLoginEmail: operationalForm.demoLoginIdentifier,
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
})
demoPasswordSet.value = result.demoLoginPasswordSet
demoManagedByDatabase.value = true
demoPasswordInput.value = ''
await loadOperationalSettings({ silent: true })
clearSiteStatusCache()
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
} catch (error) {
operationalError.value = error instanceof ApiRequestError
? error.message
: 'Operational Settings konnten nicht gespeichert werden.'
} finally {
operationalSaving.value = false
}
}
watch(
() => [
operationalForm.demoLoginEnabled,
operationalForm.demoLoginIdentifier,
operationalForm.demoLoginTwitchUserId,
operationalForm.demoLoginDisplayName,
operationalForm.maintenanceModeEnabled,
operationalForm.maintenanceTitle,
operationalForm.maintenanceMessage,
demoPasswordInput.value,
],
() => {
if (operationalLoading.value || operationalSaving.value) return
operationalError.value = ''
operationalSuccess.value = ''
},
)
onMounted(loadOperationalSettings)
return {
operationalLoading,
operationalSaving,
operationalError,
operationalSuccess,
operationalForm,
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
demoPasswordHint,
demoCredentialsComplete,
operationalSummary,
hasUnsavedOperationalChanges,
loadOperationalSettings,
saveOperationalSettings,
}
}
@@ -0,0 +1,299 @@
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
import { useAwardsStore } from '../../stores/awards'
export function useAdminReviewsManager() {
const store = useAwardsStore()
const route = useRoute()
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const reviewFilter = ref('')
const categoryFilter = ref<number | null>(null)
const selectedNominationId = ref<number | null>(null)
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
reviewNote: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
const filteredNominations = computed(() => {
const query = reviewFilter.value.trim().toLowerCase()
return seasonDetail.value.pendingNominations.filter((nomination) =>
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
.join(' ')
.toLowerCase()
.includes(query)),
)
})
const selectedNomination = computed(() =>
filteredNominations.value.find((nomination) => nomination.id === selectedNominationId.value) ?? filteredNominations.value[0] ?? null,
)
const reviewStats = computed(() => [
{ label: 'Offen', value: seasonDetail.value.pendingNominations.length },
{ label: 'Entschieden', value: seasonDetail.value.reviewedNominations.length },
{ label: 'Sichtbar', value: filteredNominations.value.length },
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
])
const reviewedNominations = computed(() => seasonDetail.value.reviewedNominations.slice(0, 12))
const categoryOptions = computed(() =>
seasonDetail.value.categories
.filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
.map((category) => ({
id: category.id,
label: category.name,
count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
})),
)
const selectedCandidateCollision = computed(() => {
if (!selectedNomination.value) return null
const form = reviewForms[selectedNomination.value.id]
if (!form) return null
const normalizedName = form.displayName.trim().toLowerCase()
const normalizedSlug = form.channelSlug.trim().toLowerCase()
return seasonDetail.value.candidates.find((candidate) =>
candidate.categoryId === selectedNomination.value?.categoryId &&
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
) ?? null
})
const selectedRelatedPendingNominations = computed(() => {
if (!selectedNomination.value) return []
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
return seasonDetail.value.pendingNominations
.filter((nomination) => {
if (nomination.id === selectedNomination.value?.id || nomination.categoryId !== selectedNomination.value?.categoryId) {
return false
}
const sameName = nomination.candidateText.trim().toLowerCase() === selectedName
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
return sameName || sameStreamUrl
})
.slice(0, 5)
})
const selectedNominationSignalSummary = computed(() => {
if (!selectedNomination.value) return null
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
nomination.categoryId === selectedNomination.value?.categoryId &&
nomination.candidateText.trim().toLowerCase() === selectedName,
)
const submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
const platforms = new Set(
relatedNominations
.map((nomination) => resolveStreamIdentity(extractNominationStreamUrl(nomination)).platform)
.filter(Boolean),
)
return {
submissions: relatedNominations.length,
uniqueSubmitters: submitters.size,
platforms: [...platforms].sort(),
}
})
const canApproveSelected = computed(() => {
if (!selectedNomination.value) return false
const form = reviewForms[selectedNomination.value.id]
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim())
})
function focusNominationFromRoute() {
const rawNominationId = Array.isArray(route.query.nominationId) ? route.query.nominationId[0] : route.query.nominationId
const nominationId = Number(rawNominationId)
if (!Number.isFinite(nominationId)) {
return false
}
const nomination = seasonDetail.value.pendingNominations.find((item) => item.id === nominationId)
if (!nomination) {
return false
}
reviewFilter.value = ''
categoryFilter.value = nomination.categoryId
selectedNominationId.value = nomination.id
return true
}
watch(
seasonDetail,
(detail) => {
for (const nomination of detail.pendingNominations) {
const streamIdentity = resolveStreamIdentity(extractNominationStreamUrl(nomination))
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: streamIdentity.channelSlug,
platform: streamIdentity.platform,
reviewNote: nomination.reviewNote ?? '',
}
}
if (focusNominationFromRoute()) {
return
}
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
}
},
{ immediate: true },
)
watch(
() => route.query.nominationId,
() => {
focusNominationFromRoute()
},
{ immediate: true },
)
watch(
filteredNominations,
(nominations) => {
if (!nominations.some((nomination) => nomination.id === selectedNominationId.value)) {
selectedNominationId.value = nominations[0]?.id ?? null
}
},
{ immediate: true },
)
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value, {
reviewNote: reviewForms[nominationId]?.reviewNote || undefined,
})
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
function selectedPlatformValue(platform: string) {
return socialIconOptionForValue(platform)?.key ?? 'custom'
}
function setPlatform(platform: string) {
if (!selectedNomination.value) return
const form = reviewForms[selectedNomination.value.id]
if (!form) return
if (platform === 'custom') {
if (socialIconOptionForValue(form.platform)) {
form.platform = ''
}
return
}
form.platform = socialIconOptionForValue(platform)?.label ?? platform
}
function handlePlatformSelection(event: Event) {
setPlatform((event.target as HTMLSelectElement).value)
}
return {
reviewSaving,
adminMessage,
adminError,
reviewForms,
seasonDetail,
reviewFilter,
categoryFilter,
selectedNominationId,
candidatePlatformOptions,
filteredNominations,
selectedNomination,
reviewStats,
reviewedNominations,
categoryOptions,
selectedCandidateCollision,
selectedRelatedPendingNominations,
selectedNominationSignalSummary,
canApproveSelected,
approveNomination,
rejectNomination,
selectedPlatformValue,
handlePlatformSelection,
extractNominationStreamUrl,
}
}
function extractNominationStreamUrl(nomination: { streamUrl?: string | null; reviewNote?: string | null }) {
if (nomination.streamUrl?.trim()) {
return nomination.streamUrl.trim()
}
const noteMatch = nomination.reviewNote?.match(/Stream-Link:\s*(https?:\/\/\S+)/i)
return noteMatch?.[1]?.trim() ?? ''
}
function resolveStreamIdentity(streamUrl: string) {
const fallback = { platform: 'Twitch', channelSlug: '' }
if (!streamUrl) return fallback
try {
const url = new URL(streamUrl)
const host = url.hostname.replace(/^www\./, '').toLowerCase()
const firstPathPart = url.pathname
.split('/')
.filter(Boolean)
.find((part) => !['c', 'channel', 'user', 'live'].includes(part.toLowerCase()))
?.replace(/^@/, '')
.replace(/[^a-zA-Z0-9._-]/g, '') ?? ''
if (host.includes('twitch.tv')) {
return { platform: 'Twitch', channelSlug: firstPathPart }
}
if (host.includes('kick.com')) {
return { platform: 'Kick', channelSlug: firstPathPart }
}
if (host.includes('youtube.com') || host.includes('youtu.be')) {
return { platform: 'YouTube', channelSlug: firstPathPart }
}
const platform = host.split('.')[0]
return {
platform: platform ? `${platform.charAt(0).toUpperCase()}${platform.slice(1)}` : fallback.platform,
channelSlug: firstPathPart,
}
} catch {
return fallback
}
}
@@ -0,0 +1,368 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminRiskFlag, AdminRiskRule } from '../../types/awards'
export type RiskResolutionStatus = 'open' | 'resolved' | 'dismissed'
export interface RiskMetadataItem {
key: string
value: string
}
type SeverityFilter = 'all' | 'high' | 'medium' | 'low'
type HistoryStatusFilter = 'all' | 'resolved' | 'dismissed'
const QUEUE_PAGE_SIZE = 12
const HISTORY_PAGE_SIZE = 10
export function riskSeverityClass(severity: string) {
const normalized = severity.toLowerCase()
if (normalized === 'high') return 'border-rose-200 bg-rose-50 text-rose-700'
if (normalized === 'medium') return 'border-amber-200 bg-amber-50 text-amber-700'
if (normalized === 'low') return 'border-sky-200 bg-sky-50 text-sky-700'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
export function riskStatusLabel(status: string) {
if (status === 'resolved') return 'Erledigt'
if (status === 'dismissed') return 'Verworfen'
if (status === 'open') return 'Offen'
return status
}
export function riskAgeLabel(flag: AdminRiskFlag) {
const createdAt = new Date(flag.createdAt)
if (Number.isNaN(createdAt.getTime())) return 'unbekannt'
const diffMinutes = Math.max(0, Math.round((Date.now() - createdAt.getTime()) / 60000))
if (diffMinutes < 60) return `${diffMinutes} min`
const diffHours = Math.round(diffMinutes / 60)
if (diffHours < 48) return `${diffHours} Std.`
return `${Math.round(diffHours / 24)} Tage`
}
export function useAdminRiskManager() {
const store = useAwardsStore()
const riskSaving = ref<number | null>(null)
const riskLoading = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const riskFilter = ref('')
const severityFilter = ref<SeverityFilter>('all')
const historyStatusFilter = ref<HistoryStatusFilter>('all')
const selectedRiskFlagId = ref<number | null>(null)
const selectedDecisionNote = ref('')
const queuePage = ref(1)
const historyPage = ref(1)
const selectedBulkRiskFlagIds = ref<number[]>([])
const bulkReviewNote = ref('')
const bulkSaving = ref(false)
const riskRules = ref<AdminRiskRule[]>([])
const riskRulesLoading = ref(false)
const riskRulesSaving = ref(false)
const queuePageSize = QUEUE_PAGE_SIZE
const historyPageSize = HISTORY_PAGE_SIZE
const riskFlags = computed(() => store.admin.riskFlags)
const riskHistory = computed(() => store.adminRiskHistory)
const riskLoadedLabel = computed(() => {
const total = store.adminRiskFlagsPage.totalCount
return total > 0 ? createPageLabel(store.adminRiskFlagsPage.offset, store.adminRiskFlagsPage.returnedCount, total) : 'keine offenen Hinweise'
})
const queuePageLabel = computed(() =>
createPageLabel(store.adminRiskFlagsPage.offset, store.adminRiskFlagsPage.returnedCount, store.adminRiskFlagsPage.totalCount),
)
const queueHasPrevious = computed(() => queuePage.value > 1)
const queueHasMore = computed(() => store.adminRiskFlagsPage.hasMore)
const historyPageLabel = computed(() =>
createPageLabel(store.adminRiskHistoryPage.offset, store.adminRiskHistoryPage.returnedCount, store.adminRiskHistoryPage.totalCount),
)
const historyHasPrevious = computed(() => historyPage.value > 1)
const historyHasMore = computed(() => store.adminRiskHistoryPage.hasMore)
const filteredRiskFlags = computed(() => riskFlags.value)
const selectedRiskFlag = computed(() =>
filteredRiskFlags.value.find((flag) => flag.id === selectedRiskFlagId.value) ?? filteredRiskFlags.value[0] ?? null,
)
const selectedRiskMetadata = computed<RiskMetadataItem[]>(() => parseRiskMetadata(selectedRiskFlag.value?.metadataJson))
const selectedDecisionNoteLength = computed(() => selectedDecisionNote.value.trim().length)
const selectedDecisionReady = computed(() => selectedDecisionNoteLength.value >= 3 && selectedDecisionNoteLength.value <= 500)
const canBulkResolve = computed(() =>
selectedBulkRiskFlagIds.value.length > 0 &&
bulkReviewNote.value.trim().length >= 3 &&
bulkReviewNote.value.trim().length <= 500,
)
const riskStats = computed(() => {
const highCount = countFor(store.adminRiskFlagsPage.severityCounts, 'high')
const uniqueUsers = new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size
return [
{ label: 'Offen', value: store.adminRiskFlagsPage.totalCount, tone: store.adminRiskFlagsPage.totalCount > 0 ? 'warning' : 'ok' },
{ label: 'High', value: highCount, tone: highCount > 0 ? 'danger' : 'ok' },
{ label: 'Medium', value: countFor(store.adminRiskFlagsPage.severityCounts, 'medium'), tone: 'warning' },
{ label: 'User betroffen', value: uniqueUsers, tone: 'neutral' },
]
})
const severityFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: store.adminRiskFlagsPage.totalCount },
{ key: 'high' as const, label: 'High', count: countFor(store.adminRiskFlagsPage.severityCounts, 'high') },
{ key: 'medium' as const, label: 'Medium', count: countFor(store.adminRiskFlagsPage.severityCounts, 'medium') },
{ key: 'low' as const, label: 'Low', count: countFor(store.adminRiskFlagsPage.severityCounts, 'low') },
])
const recentRiskHistory = computed(() => riskHistory.value)
const riskHistoryStats = computed(() => [
{ label: 'Entschieden', value: store.adminRiskHistoryPage.totalCount },
{ label: 'Erledigt', value: countFor(store.adminRiskHistoryPage.statusCounts, 'resolved') },
{ label: 'Verworfen', value: countFor(store.adminRiskHistoryPage.statusCounts, 'dismissed') },
])
const historyStatusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: store.adminRiskHistoryPage.totalCount },
{ key: 'resolved' as const, label: 'Erledigt', count: countFor(store.adminRiskHistoryPage.statusCounts, 'resolved') },
{ key: 'dismissed' as const, label: 'Verworfen', count: countFor(store.adminRiskHistoryPage.statusCounts, 'dismissed') },
])
async function loadRiskFlags() {
riskLoading.value = true
adminError.value = ''
try {
const query = riskFilter.value.trim()
await Promise.all([
store.loadAdminRiskFlagsPage({
limit: queuePageSize,
offset: (queuePage.value - 1) * queuePageSize,
status: 'open',
severity: severityFilter.value === 'all' ? undefined : severityFilter.value,
query,
}),
store.loadAdminRiskHistoryPage({
limit: historyPageSize,
offset: (historyPage.value - 1) * historyPageSize,
status: historyStatusFilter.value,
reviewedOnly: true,
query,
}),
])
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risikohinweise konnten nicht geladen werden.'
} finally {
riskLoading.value = false
}
}
async function setQueuePage(page: number) {
queuePage.value = Math.max(1, page)
selectedBulkRiskFlagIds.value = []
await loadRiskFlags()
}
async function setHistoryPage(page: number) {
historyPage.value = Math.max(1, page)
await loadRiskFlags()
}
async function updateRiskFlagStatus(riskFlagId: number, status: RiskResolutionStatus) {
if (riskSaving.value) return
if (status !== 'open' && !selectedDecisionReady.value) {
adminError.value = 'Bitte notiere kurz, warum du diesen Hinweis entscheidest.'
return
}
riskSaving.value = riskFlagId
adminMessage.value = ''
adminError.value = ''
try {
const reviewNote = status === 'open' ? 'Wieder geöffnet.' : selectedDecisionNote.value.trim()
await store.resolveRiskFlag(riskFlagId, { status, reviewNote })
selectedDecisionNote.value = ''
await loadRiskFlags()
adminMessage.value = `Risikohinweis ${riskFlagId} wurde aktualisiert.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risikohinweis konnte nicht aktualisiert werden.'
} finally {
riskSaving.value = null
}
}
function toggleBulkRiskFlag(riskFlagId: number) {
const flag = filteredRiskFlags.value.find((item) => item.id === riskFlagId)
if (!flag || flag.severity.toLowerCase() !== 'low') return
selectedBulkRiskFlagIds.value = selectedBulkRiskFlagIds.value.includes(riskFlagId)
? selectedBulkRiskFlagIds.value.filter((id) => id !== riskFlagId)
: [...selectedBulkRiskFlagIds.value, riskFlagId]
}
function selectVisibleLowRiskFlags() {
selectedBulkRiskFlagIds.value = filteredRiskFlags.value
.filter((flag) => flag.severity.toLowerCase() === 'low')
.map((flag) => flag.id)
}
function clearBulkSelection() {
selectedBulkRiskFlagIds.value = []
bulkReviewNote.value = ''
}
async function bulkResolveRiskFlags(status: Exclude<RiskResolutionStatus, 'open'>) {
if (!canBulkResolve.value || bulkSaving.value) return
bulkSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.bulkResolveRiskFlags({
riskFlagIds: selectedBulkRiskFlagIds.value,
status,
reviewNote: bulkReviewNote.value.trim(),
})
adminMessage.value = `${selectedBulkRiskFlagIds.value.length} Risikohinweise wurden aktualisiert.`
clearBulkSelection()
await loadRiskFlags()
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Bulk-Entscheidung konnte nicht gespeichert werden.'
} finally {
bulkSaving.value = false
}
}
function updateRiskRule(ruleKey: string, patch: Partial<AdminRiskRule>) {
riskRules.value = riskRules.value.map((rule) =>
rule.key === ruleKey ? { ...rule, ...patch } : rule,
)
}
async function saveRiskRules() {
riskRulesSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
const response = await store.updateAdminRiskRules({ rules: riskRules.value })
riskRules.value = response.rules
adminMessage.value = 'Risk-Regeln wurden gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risk-Regeln konnten nicht gespeichert werden.'
} finally {
riskRulesSaving.value = false
}
}
async function loadRiskRules() {
riskRulesLoading.value = true
try {
const response = await store.loadAdminRiskRules()
riskRules.value = response.rules
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risk-Regeln konnten nicht geladen werden.'
} finally {
riskRulesLoading.value = false
}
}
watch(filteredRiskFlags, (flags) => {
if (!flags.some((flag) => flag.id === selectedRiskFlagId.value)) {
selectedRiskFlagId.value = flags[0]?.id ?? null
}
}, { immediate: true })
watch(selectedRiskFlag, (flag) => {
selectedDecisionNote.value = flag?.reviewNote ?? ''
})
let filterReloadTimer: number | undefined
watch([riskFilter, severityFilter, historyStatusFilter], () => {
queuePage.value = 1
historyPage.value = 1
window.clearTimeout(filterReloadTimer)
filterReloadTimer = window.setTimeout(() => {
void loadRiskFlags()
}, 250)
})
onMounted(() => {
void loadRiskFlags()
void loadRiskRules()
})
return {
riskSaving,
riskLoading,
adminMessage,
adminError,
riskFilter,
severityFilter,
historyStatusFilter,
selectedRiskFlagId,
selectedDecisionNote,
selectedDecisionNoteLength,
selectedDecisionReady,
queuePage,
queuePageLabel,
queueHasPrevious,
queueHasMore,
historyPage,
historyPageLabel,
historyHasPrevious,
historyHasMore,
selectedBulkRiskFlagIds,
bulkReviewNote,
bulkSaving,
canBulkResolve,
riskRules,
riskRulesLoading,
riskRulesSaving,
riskFlags,
riskHistory,
filteredRiskFlags,
selectedRiskFlag,
selectedRiskMetadata,
riskStats,
riskHistoryStats,
recentRiskHistory,
severityFilters,
historyStatusFilters,
riskLoadedLabel,
loadRiskFlags,
updateRiskFlagStatus,
setQueuePage,
setHistoryPage,
toggleBulkRiskFlag,
selectVisibleLowRiskFlags,
clearBulkSelection,
bulkResolveRiskFlags,
updateRiskRule,
saveRiskRules,
}
}
function countFor(counts: Array<{ key: string; count: number }>, key: string) {
return counts.find((item) => item.key === key)?.count ?? 0
}
function createPageLabel(offset: number, returnedCount: number, totalCount: number) {
if (totalCount === 0) return '0 Treffer'
const start = offset + 1
const end = offset + returnedCount
return `${start}-${end} / ${totalCount}`
}
function parseRiskMetadata(metadataJson: string | undefined) {
if (!metadataJson) return []
try {
const parsed = JSON.parse(metadataJson) as Record<string, unknown>
return Object.entries(parsed)
.filter(([key]) => key !== 'entityLinks')
.map(([key, value]) => ({
key,
value: typeof value === 'string' ? value : JSON.stringify(value),
}))
} catch {
return [{ key: 'raw', value: metadataJson }]
}
}
@@ -0,0 +1,566 @@
import { computed, reactive, ref, watch } from 'vue'
import { api } from '../../lib/api'
import { useAwardsStore } from '../../stores/awards'
import type { AdminAuditEntry, AdminSeasonDetailResponse } from '../../types/awards'
import { hasShowDayPassed, normalizePhaseKey } from './adminSeasonTimeline'
import type {
AdminSeasonCopyOption,
AdminSeasonCreateForm,
AdminSeasonForm,
AdminSeasonReadinessItem,
} from './adminSeasonTypes'
function createEmptySeasonForm(): AdminSeasonForm {
return {
year: new Date().getFullYear(),
name: '',
showStreamUrl: '',
currentPhase: '',
isCurrent: false,
isCommunityOnly: true,
nominationStartsAt: '',
nominationEndsAt: '',
votingStartsAt: '',
votingEndsAt: '',
reviewStartsAt: '',
reviewEndsAt: '',
showDate: '',
showStartsAt: '20:00',
}
}
function createEmptyCreateForm(): AdminSeasonCreateForm {
return {
year: new Date().getFullYear() + 1,
name: '',
showStreamUrl: 'https://twitch.tv/jayuhime',
currentPhase: 'Nominierung',
isCurrent: false,
isCommunityOnly: true,
nominationStartsAt: '',
nominationEndsAt: '',
votingStartsAt: '',
votingEndsAt: '',
reviewStartsAt: '',
reviewEndsAt: '',
showDate: '',
showStartsAt: '20:00',
copyStructureFromSeasonId: null,
}
}
export function useAdminSeasonManager() {
const store = useAwardsStore()
const saving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const createModalOpen = ref(false)
const creating = ref(false)
const seasonToDelete = ref<{ id: number; year: number; name: string; isCurrent: boolean } | null>(null)
const deleting = ref(false)
const completing = ref(false)
const seasonAuditEntries = ref<AdminAuditEntry[]>([])
const loadingSeasonAudit = ref(false)
let seasonAuditRequestId = 0
const form = reactive(createEmptySeasonForm())
const createForm = reactive(createEmptyCreateForm())
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const selectedSeason = computed(() =>
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
)
const phasePresets = ['Nominierung', 'Community Voting', 'Review & Auswertung', 'Award Show', 'Abgeschlossen']
const readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
const publicReadinessIssues = computed(() =>
readinessItems.value
.filter((item) => item.blocking && !item.complete)
.map((item) => item.note),
)
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
const canCompleteSelectedSeason = computed(() =>
Boolean(
selectedSeason.value &&
normalizePhaseKey(form.currentPhase) !== 'completed' &&
archiveReadinessIssues.value.length === 0,
),
)
const canDeleteSelectedSeason = computed(() => Boolean(selectedSeason.value && !selectedSeason.value.isCurrent))
const copySourceOptions = computed<AdminSeasonCopyOption[]>(() => [
{ label: 'Keine Struktur kopieren', value: null },
...store.adminSeasons.map((season) => ({
label: `${season.year} · ${season.name} (${season.categoryCount} Kategorien)`,
value: season.id,
})),
])
const canCreate = computed(() =>
Boolean(
createForm.year &&
createForm.name.trim() &&
createForm.showStreamUrl.trim() &&
createForm.currentPhase.trim() &&
createForm.nominationStartsAt &&
createForm.nominationEndsAt &&
createForm.votingStartsAt &&
createForm.votingEndsAt &&
createForm.reviewStartsAt &&
createForm.reviewEndsAt &&
createForm.showDate &&
createForm.showStartsAt &&
createPublicReadinessIssues.value.length === 0,
),
)
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
const latestSeasonAuditSummary = computed(() =>
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion fuer dieses Jahr gefunden.',
)
const latestSeasonAuditMeta = computed(() => {
const entry = latestSeasonAuditEntry.value
return entry ? `${formatAuditDate(entry.createdAt)} · ${entry.adminTwitchUserId}` : 'Audit wird nach der ersten Aenderung angezeigt.'
})
watch(
seasonDetail,
(detail) => {
form.year = detail.year
form.name = detail.name
form.showStreamUrl = detail.showStreamUrl
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
form.isCommunityOnly = detail.isCommunityOnly
form.nominationStartsAt = detail.nominationStartsAt
form.nominationEndsAt = detail.nominationEndsAt
form.votingStartsAt = detail.votingStartsAt
form.votingEndsAt = detail.votingEndsAt
form.reviewStartsAt = detail.reviewStartsAt
form.reviewEndsAt = detail.reviewEndsAt
form.showDate = detail.showDate
form.showStartsAt = normalizeTimeInput(detail.showStartsAt)
},
{ immediate: true },
)
watch(
() => createForm.year,
(year) => {
if (year >= 2020 && (!createForm.name || createForm.name.startsWith('VTuber Star Awards '))) {
createForm.name = `VTuber Star Awards ${year}`
}
},
)
watch(
() => [selectedSeasonId.value, seasonDetail.value.year, seasonDetail.value.name] as const,
() => {
void loadSeasonAuditEntries()
},
{ immediate: true },
)
function fillCreateDefaults(year = new Date().getFullYear() + 1) {
createForm.year = year
createForm.name = `VTuber Star Awards ${year}`
createForm.showStreamUrl = 'https://twitch.tv/jayuhime'
createForm.currentPhase = 'Nominierung'
createForm.isCurrent = false
createForm.isCommunityOnly = true
createForm.nominationStartsAt = `${year}-08-01`
createForm.nominationEndsAt = `${year}-08-24`
createForm.votingStartsAt = `${year}-08-25`
createForm.votingEndsAt = `${year}-09-11`
createForm.reviewStartsAt = `${year}-09-12`
createForm.reviewEndsAt = `${year}-09-15`
createForm.showDate = `${year}-09-20`
createForm.showStartsAt = '20:00'
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
}
function openCreateModal() {
adminMessage.value = ''
adminError.value = ''
fillCreateDefaults(
Math.max(new Date().getFullYear(), (store.adminSeasons[0]?.year ?? new Date().getFullYear()) + 1),
)
createModalOpen.value = true
}
async function persistSeason(successMessage: string) {
if (!selectedSeasonId.value) {
return false
}
const completesSeasonNow =
normalizePhaseKey(form.currentPhase) === 'completed' &&
normalizePhaseKey(seasonDetail.value.currentPhase) !== 'completed'
if (completesSeasonNow && archiveReadinessIssues.value.length > 0) {
adminMessage.value = ''
adminError.value = `Abschluss blockiert: ${archiveReadinessIssues.value.join(' ')}`
return false
}
if (form.isCurrent && publicReadinessIssues.value.length > 0) {
adminMessage.value = ''
adminError.value = `Public-Aktivierung blockiert: ${publicReadinessIssues.value.join(' ')}`
return false
}
saving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, normalizeSeasonPayload(form))
await loadSeasonAuditEntries()
adminMessage.value = successMessage
return true
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
return false
} finally {
saving.value = false
}
}
async function saveSeason() {
return persistSeason('Jahresstatus gespeichert.')
}
async function activatePhase(phase: string) {
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
return
}
const previousPhase = form.currentPhase
form.currentPhase = phase
const saved = await persistSeason(`Phase „${phase}“ wurde aktiviert.`)
if (!saved) {
form.currentPhase = previousPhase
}
}
async function completeSeason() {
if (!selectedSeasonId.value || completing.value) {
return
}
completing.value = true
adminMessage.value = ''
adminError.value = ''
try {
if (archiveReadinessIssues.value.length > 0) {
adminError.value = `Abschluss blockiert: ${archiveReadinessIssues.value.join(' ')}`
return
}
const payload = {
...form,
currentPhase: 'Abgeschlossen',
}
await store.updateAdminSeason(selectedSeasonId.value, normalizeSeasonPayload(payload))
await loadSeasonAuditEntries()
form.currentPhase = 'Abgeschlossen'
adminMessage.value = `Award-Jahr ${form.year} wurde abgeschlossen.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht abgeschlossen werden.'
} finally {
completing.value = false
}
}
async function createSeason() {
if (!canCreate.value) {
if (createPublicReadinessIssues.value.length > 0) {
adminError.value = `Direkte Public-Aktivierung blockiert: ${createPublicReadinessIssues.value.join(' ')}`
}
return
}
creating.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminSeason(normalizeSeasonPayload(createForm))
createModalOpen.value = false
adminMessage.value = createForm.copyStructureFromSeasonId
? `Award-Jahr ${createForm.year} wurde mit kopierter Struktur angelegt.`
: `Award-Jahr ${createForm.year} wurde angelegt.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht angelegt werden.'
} finally {
creating.value = false
}
}
function openDeleteSeasonModal() {
if (!selectedSeason.value) {
return
}
seasonToDelete.value = {
id: selectedSeason.value.id,
year: selectedSeason.value.year,
name: selectedSeason.value.name,
isCurrent: selectedSeason.value.isCurrent,
}
}
async function confirmDeleteSeason() {
if (!seasonToDelete.value || seasonToDelete.value.isCurrent) {
return
}
deleting.value = true
adminMessage.value = ''
adminError.value = ''
try {
const deletedYear = seasonToDelete.value.year
await store.deleteAdminSeason(seasonToDelete.value.id)
await loadSeasonAuditEntries()
seasonToDelete.value = null
adminMessage.value = `Award-Jahr ${deletedYear} wurde gelöscht.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht gelöscht werden.'
} finally {
deleting.value = false
}
}
function findCopySourceForYear(year: number) {
const previousSeason = [...store.adminSeasons]
.filter((season) => season.year < year)
.sort((left, right) => right.year - left.year)[0]
return previousSeason?.id ?? store.adminSeasons[0]?.id ?? null
}
async function loadSeasonAuditEntries() {
const detail = seasonDetail.value
if (!detail.id) {
seasonAuditEntries.value = []
return
}
const requestId = ++seasonAuditRequestId
loadingSeasonAudit.value = true
try {
const entries = await api.getAdminAuditEntries(100, 'season')
if (requestId !== seasonAuditRequestId) {
return
}
const knownEntityIds = new Set([String(detail.id), String(detail.year)])
seasonAuditEntries.value = entries
.filter((entry) =>
entry.entityType === 'season' &&
knownEntityIds.has(entry.entityId) &&
entry.actionType.startsWith('season.'),
)
.slice(0, 5)
} catch {
if (requestId === seasonAuditRequestId) {
seasonAuditEntries.value = []
}
} finally {
if (requestId === seasonAuditRequestId) {
loadingSeasonAudit.value = false
}
}
}
return {
store,
form,
createForm,
saving,
adminMessage,
adminError,
createModalOpen,
creating,
completing,
seasonToDelete,
deleting,
seasonDetail,
selectedSeasonId,
selectedSeason,
readinessItems,
publicReadinessIssues,
archiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
canDeleteSelectedSeason,
canCompleteSelectedSeason,
copySourceOptions,
loadingSeasonAudit,
latestSeasonAuditSummary,
latestSeasonAuditMeta,
canCreate,
activatePhase,
openCreateModal,
saveSeason,
completeSeason,
createSeason,
openDeleteSeasonModal,
confirmDeleteSeason,
}
}
function normalizeTimeInput(value: string) {
return value ? value.slice(0, 5) : '20:00'
}
function normalizeTimeForApi(value: string) {
const normalized = normalizeTimeInput(value)
return normalized.length === 5 ? `${normalized}:00` : normalized
}
function normalizeSeasonPayload<T extends { showStartsAt: string }>(payload: T): T {
return {
...payload,
showStartsAt: normalizeTimeForApi(payload.showStartsAt),
}
}
function resolveSeasonFacts(detail: AdminSeasonDetailResponse) {
const categoryIdsWithCandidates = new Set(detail.candidates.map((candidate) => candidate.categoryId))
const categoryIdsWithResults = new Set(detail.results.map((result) => result.categoryId))
const emptyCategories = detail.categories.filter((category) => !categoryIdsWithCandidates.has(category.id)).length
const missingResults = detail.categories.filter((category) => !categoryIdsWithResults.has(category.id)).length
const pendingClips = detail.clipSubmissions.filter((clip) => clip.status === 'pending').length
return {
emptyCategories,
missingResults,
pendingClips,
}
}
function buildReadinessItems(
detail: AdminSeasonDetailResponse,
currentPhase: string,
): AdminSeasonReadinessItem[] {
const facts = resolveSeasonFacts(detail)
const phaseKey = normalizePhaseKey(currentPhase)
const candidateBlocking = phaseKey !== 'nomination'
const winnerBlocking = phaseKey === 'completed'
return [
{
label: 'Kategorien',
note: detail.categories.length > 0
? `${detail.categories.length} Kategorien vorbereitet`
: 'Mindestens eine Kategorie ist erforderlich.',
complete: detail.categories.length > 0,
blocking: true,
to: '/admin/categories',
},
{
label: 'Kandidatenbasis',
note: facts.emptyCategories === 0 && detail.candidates.length > 0
? 'Alle Kategorien haben Kandidaten.'
: candidateBlocking
? `${facts.emptyCategories} Kategorien brauchen noch Kandidaten.`
: 'In der Nominierung duerfen Kategorien noch leer sein.',
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && detail.candidates.length > 0,
blocking: candidateBlocking,
to: '/admin/candidates',
},
{
label: 'Reviews',
note: detail.pendingNominations.length === 0
? 'Keine offenen Nominierungsreviews.'
: `${detail.pendingNominations.length} Reviews sollten vor Voting-Freeze entschieden werden.`,
complete: detail.pendingNominations.length === 0,
blocking: false,
to: '/admin/nominations?review=1',
},
{
label: 'Clips',
note: facts.pendingClips === 0
? 'Keine offenen Clip-Pruefungen.'
: `${facts.pendingClips} Clips warten noch auf Pruefung.`,
complete: facts.pendingClips === 0,
blocking: false,
to: '/admin/clips',
},
{
label: 'Gewinner / Archiv',
note: detail.categories.length > 0 && facts.missingResults === 0
? 'Alle Gewinner sind vergeben.'
: winnerBlocking
? `${facts.missingResults} Kategorien brauchen vor Abschluss einen Gewinner.`
: 'Vor Abschluss muessen alle Gewinner gesetzt sein.',
complete: detail.categories.length > 0 && facts.missingResults === 0,
blocking: winnerBlocking,
to: '/admin/analytics',
},
]
}
function buildArchiveReadinessIssues(detail: AdminSeasonDetailResponse) {
const facts = resolveSeasonFacts(detail)
const issues: string[] = []
if (!hasShowDayPassed(detail)) {
issues.push(
detail.showDate
? 'Die Award Show liegt noch nicht in der Vergangenheit.'
: 'Lege zuerst ein Datum für die Award Show fest.',
)
}
if (detail.categories.length === 0) {
issues.push('Mindestens eine Kategorie ist erforderlich.')
}
if (facts.emptyCategories > 0) {
issues.push(`${facts.emptyCategories} Kategorien haben noch keine Kandidaten.`)
}
if (facts.missingResults > 0) {
issues.push(`${facts.missingResults} Kategorien haben noch keinen Gewinner.`)
}
return issues
}
function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
if (!createForm.isCurrent) {
return []
}
const phaseKey = normalizePhaseKey(createForm.currentPhase)
const issues: string[] = []
if (!createForm.copyStructureFromSeasonId) {
issues.push('Kopiere erst eine Vorjahres-Struktur oder lege Kategorien nach dem Erstellen manuell an.')
}
if (phaseKey !== 'nomination') {
issues.push('Direkt public ist fuer neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
}
return issues
}
function formatAuditDate(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -0,0 +1,181 @@
import { computed, onMounted, ref } from 'vue'
import { CheckCircle2, Database, FileText, Link2, ShieldCheck, Tags, UserRound } from '@lucide/vue'
import { getRiskMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import type { AdminSettingsCheckItem, AdminSettingsGateItem } from './adminSettingsTypes'
export function useAdminSettingsOverview() {
const store = useAwardsStore()
const healthLoading = ref(false)
const healthError = ref('')
const healthLoadedAt = ref<Date | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const databaseHealth = computed(() => store.databaseHealth)
const hasVotingPhase = computed(() => seasonDetail.value.currentPhase.toLowerCase().includes('voting'))
const categoriesWithoutCandidates = computed(() =>
seasonDetail.value.categories.filter((category) =>
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
),
)
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const healthLoadedLabel = computed(() =>
healthLoadedAt.value ? healthLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
)
const siteSettings = computed(() => store.adminSiteSettings)
const configuredFooterLinks = computed(() => [
siteSettings.value.imprintUrl,
siteSettings.value.contactUrl,
siteSettings.value.sponsorsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
label: 'Host',
value: Boolean(siteSettings.value.hostDisplayName.trim() && siteSettings.value.hostTagline.trim()),
note: siteSettings.value.hostDisplayName.trim()
? siteSettings.value.hostTagline.trim() ? siteSettings.value.hostDisplayName.trim() : 'Host-Tagline fehlt'
: 'Kein Host-Name hinterlegt',
icon: UserRound,
to: '/admin/content',
},
{
label: 'Social Links',
value: siteSettings.value.socialLinks.length > 0,
note: `${siteSettings.value.socialLinks.length} Links gespeichert`,
icon: Link2,
to: '/admin/content',
},
{
label: 'FAQ',
value: siteSettings.value.faq.length > 0,
note: `${siteSettings.value.faq.length} Fragen gespeichert`,
icon: FileText,
to: '/admin/content',
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4,
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
icon: Link2,
to: '/admin/content',
},
{
label: 'Datenschutz',
value: Boolean(siteSettings.value.privacyPolicyContent.trim() && siteSettings.value.privacyEmail.trim()),
note: siteSettings.value.privacyPolicyUpdatedAt
? `Zuletzt aktualisiert: ${new Date(siteSettings.value.privacyPolicyUpdatedAt).toLocaleString('de-DE')}`
: 'Datenschutztext oder Datenschutz-Mail fehlt',
icon: ShieldCheck,
to: '/admin/content',
},
])
const contentCompletion = computed(() => contentChecks.value.filter((check) => check.value).length)
const checks = computed<AdminSettingsCheckItem[]>(() => [
{
label: 'Backend verbunden',
value: store.apiMode === 'api',
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
icon: Database,
to: null,
},
{
label: 'Postgres verbunden',
value: databaseHealth.value.canConnect,
note: databaseHealth.value.canConnect
? `${databaseHealth.value.provider} erreichbar · Quelle: ${databaseHealth.value.configuredConnection.source}.`
: databaseHealth.value.error || 'Datenbank ist nicht erreichbar.',
icon: Database,
to: null,
},
{
label: 'Migrationen aktuell',
value: databaseHealth.value.canConnect && pendingMigrationCount.value === 0,
note: pendingMigrationCount.value === 0
? 'Keine ausstehenden Migrationen.'
: `${pendingMigrationCount.value} Migrationen warten auf Anwendung.`,
icon: CheckCircle2,
to: null,
},
{
label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
icon: CheckCircle2,
to: '/admin/years',
},
{
label: 'Voting-Basis vollständig',
value: categoriesWithoutCandidates.value.length === 0 && seasonDetail.value.categories.length > 0,
note: categoriesWithoutCandidates.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${categoriesWithoutCandidates.value.length} Kategorien brauchen Kandidaten.`,
icon: Tags,
to: '/admin/categories',
},
{
label: 'Risiko-Queue leer',
value: openRiskCount.value === 0,
note: `${openRiskCount.value} offene Risikohinweise im Admin-Kontext.`,
icon: ShieldCheck,
to: '/admin/risk',
},
])
const gates = computed<AdminSettingsGateItem[]>(() => [
{
label: 'Nominierungsfenster',
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
note: seasonDetail.value.currentPhase.toLowerCase().includes('nomin')
? 'Öffentliche Nominierung ist aktiv.'
: 'Die aktuelle Phase erlaubt keine neuen Nominierungen.',
to: '/admin/years',
},
{
label: 'Voting freigeschaltet',
state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
to: '/admin/categories',
},
{
label: 'Clip-Reviews offen',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
},
{
label: 'Freitext-Reviews geklärt',
state: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Freitext-Reviews.`,
to: '/admin/nominations?review=1',
},
])
async function refreshDatabaseHealth() {
healthLoading.value = true
healthError.value = ''
try {
await store.loadDatabaseHealth()
healthLoadedAt.value = new Date()
} catch (error) {
healthError.value = error instanceof Error ? error.message : 'Datenbank-Healthcheck konnte nicht geladen werden.'
} finally {
healthLoading.value = false
}
}
onMounted(refreshDatabaseHealth)
return {
healthLoading,
healthError,
databaseHealth,
pendingMigrationCount,
healthLoadedLabel,
contentChecks,
contentCompletion,
checks,
gates,
refreshDatabaseHealth,
}
}
@@ -0,0 +1,171 @@
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
import { useAwardsStore } from '../../stores/awards'
const allFilter = 'all'
export function useAdminWinnersManager() {
const store = useAwardsStore()
const savingResultForCategory = ref<number | null>(null)
const deletingResultId = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const query = ref('')
const statusFilter = ref(allFilter)
const winnerSelections = reactive<Record<number, string>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
const resultRows = computed(() =>
seasonDetail.value.categories
.map((category) => {
const candidates = seasonDetail.value.candidates
.filter((candidate) => candidate.categoryId === category.id)
.sort((a, b) => a.displayName.localeCompare(b.displayName))
const existing = resultMap.value.get(category.id) ?? null
const openReviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
const approvedClips = seasonDetail.value.clipSubmissions.filter((clip) => clip.categoryId === category.id && clip.status === 'approved').length
return {
category,
candidates,
existing,
openReviews,
approvedClips,
hasPendingReviews: openReviews > 0,
isEmpty: candidates.length === 0,
isComplete: Boolean(existing),
}
})
.sort((a, b) => a.category.sortOrder - b.category.sortOrder),
)
const completedCount = computed(() => resultRows.value.filter((row) => row.isComplete).length)
const missingCount = computed(() => Math.max(0, resultRows.value.length - completedCount.value))
const reviewWarningCount = computed(() => resultRows.value.filter((row) => row.hasPendingReviews).length)
const emptyCount = computed(() => resultRows.value.filter((row) => row.isEmpty).length)
const completionPct = computed(() => resultRows.value.length === 0 ? 0 : Math.round((completedCount.value / resultRows.value.length) * 100))
const statusFilters = computed(() => [
{ value: allFilter, label: 'Alle', count: resultRows.value.length },
{ value: 'open', label: 'Offen', count: missingCount.value },
{ value: 'set', label: 'Gesetzt', count: completedCount.value },
{ value: 'review', label: 'Reviews offen', count: reviewWarningCount.value },
{ value: 'empty', label: 'Ohne Kandidaten', count: emptyCount.value },
])
const visibleResultRows = computed(() => {
const normalizedQuery = query.value.trim().toLowerCase()
return resultRows.value.filter((row) => {
const matchesStatus = statusFilter.value === allFilter
|| (statusFilter.value === 'open' && !row.isComplete)
|| (statusFilter.value === 'set' && row.isComplete)
|| (statusFilter.value === 'review' && row.hasPendingReviews)
|| (statusFilter.value === 'empty' && row.isEmpty)
if (!matchesStatus) return false
if (!normalizedQuery) return true
return [
row.category.name,
row.category.groupName,
row.existing?.candidateDisplayName ?? '',
...row.candidates.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
].some((value) => value.toLowerCase().includes(normalizedQuery))
})
})
const summaryCards = computed(() => [
{
label: 'Fortschritt',
value: `${completionPct.value}%`,
note: `${completedCount.value} von ${resultRows.value.length} Kategorien final.`,
icon: Trophy,
tone: 'border-violet-100 bg-violet-50 text-violet-800',
},
{
label: 'Offen',
value: String(missingCount.value),
note: missingCount.value === 0 ? 'Keine Gewinner fehlen.' : 'Diese Kategorien brauchen eine Auswahl.',
icon: ListChecks,
tone: missingCount.value > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
label: 'Reviews',
value: String(reviewWarningCount.value),
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
icon: Clock3,
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
{
label: 'Gesetzt',
value: String(completedCount.value),
note: 'Speist Archiv und Gewinneransicht.',
icon: CheckCircle2,
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
},
])
watch(resultRows, (rows) => {
const knownCategoryIds = new Set(rows.map((row) => row.category.id))
for (const key of Object.keys(winnerSelections)) {
if (!knownCategoryIds.has(Number(key))) {
delete winnerSelections[Number(key)]
}
}
for (const row of rows) {
winnerSelections[row.category.id] = row.existing?.candidateId ? String(row.existing.candidateId) : ''
}
}, { immediate: true })
async function saveWinner(categoryId: number) {
const candidateId = Number(winnerSelections[categoryId])
if (!candidateId || !seasonDetail.value.id) return
savingResultForCategory.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.setAdminResult(seasonDetail.value.id, { categoryId, candidateId })
adminMessage.value = 'Gewinner wurde gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht gespeichert werden.'
} finally {
savingResultForCategory.value = null
}
}
async function clearWinner(resultId: number) {
if (!seasonDetail.value.id) return
deletingResultId.value = resultId
adminMessage.value = ''
adminError.value = ''
try {
await store.deleteAdminResult(resultId, seasonDetail.value.id)
adminMessage.value = 'Gewinner wurde entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht entfernt werden.'
} finally {
deletingResultId.value = null
}
}
return {
adminError,
adminMessage,
completionPct,
deletingResultId,
query,
savingResultForCategory,
statusFilter,
statusFilters,
summaryCards,
visibleResultRows,
winnerSelections,
clearWinner,
saveWinner,
}
}