Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||
import AdminToastViewport from './admin/AdminToastViewport.vue'
|
||||
import { useAdminApiDisconnectToast } from '../composables/useAdminApiDisconnectToast'
|
||||
import { useBodyScrollLock } from '../composables/useBodyScrollLock'
|
||||
import { privacyContentToHtml } from '../lib/privacyContent'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -35,6 +37,7 @@ const navItems = [
|
||||
]
|
||||
|
||||
const visibleNavItems = computed(() => navItems)
|
||||
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||
const privacyContent = computed(
|
||||
() => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent,
|
||||
)
|
||||
@@ -140,6 +143,7 @@ const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans
|
||||
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
|
||||
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
||||
|
||||
useAdminApiDisconnectToast(isAdminRoute)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -231,6 +235,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AdminToastViewport />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -31,6 +31,41 @@
|
||||
<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>
|
||||
|
||||
<section v-if="identitySummary || ruleNotices.length" class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Regel-Kontext</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Diese Werte helfen bei mehrfachen Nominierungen und Gewinnergrenzen.</p>
|
||||
</div>
|
||||
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-4">
|
||||
<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">Kandidaturen</p>
|
||||
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.appearances }}</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">Angenommen</p>
|
||||
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.acceptedAppearances }}</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">Gewinner</p>
|
||||
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.winnerPlacements }}</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">Viewer-Nominierungen</p>
|
||||
<strong class="mt-1 block text-lg text-violet-900">{{ identitySummary.nominationTally }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ruleNotices.length" class="mt-3 space-y-2">
|
||||
<p
|
||||
v-for="notice in ruleNotices"
|
||||
:key="notice.message"
|
||||
class="rounded-2xl border px-4 py-3 text-sm font-semibold"
|
||||
:class="notice.mode === 'block' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-amber-100 bg-amber-50 text-amber-700'"
|
||||
>
|
||||
{{ notice.mode === 'block' ? 'Blockiert' : 'Warnung' }}: {{ notice.message }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-violet-100 bg-violet-50/40 p-4">
|
||||
<div class="mb-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Annahmestatus</p>
|
||||
@@ -128,6 +163,13 @@ defineProps<{
|
||||
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
||||
clipEmbedStatusOptions: Array<{ label: string; value: string }>
|
||||
selectedPlatformValue: string
|
||||
identitySummary: {
|
||||
appearances: number
|
||||
acceptedAppearances: number
|
||||
winnerPlacements: number
|
||||
nominationTally: number
|
||||
} | null
|
||||
ruleNotices: Array<{ mode: 'warn' | 'block'; message: string }>
|
||||
canSave: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
<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 class="mt-1 text-xs text-slate-500">
|
||||
{{ candidate.nominationTally }} Viewer-Nominierungen
|
||||
<template v-if="candidateIdentitySummaries[identityKey(candidate)]">
|
||||
· {{ candidateIdentitySummaries[identityKey(candidate)].appearances }} Kandidaturen
|
||||
· {{ candidateIdentitySummaries[identityKey(candidate)].winnerPlacements }} Gewinnerplätze
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||
Mögliches Duplikat in dieser Kategorie
|
||||
</p>
|
||||
@@ -122,6 +129,7 @@ const props = defineProps<{
|
||||
rangeEnd: number
|
||||
categoryLabelMap: Record<number, string>
|
||||
duplicateCandidateKeys: Map<string, number>
|
||||
candidateIdentitySummaries: Record<string, { appearances: number; acceptedAppearances: number; winnerPlacements: number; nominationTally: number }>
|
||||
candidateWorkflowNotices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>
|
||||
acceptanceStatusOptions: Array<{ label: string; value: string; description: string }>
|
||||
}>()
|
||||
@@ -138,6 +146,15 @@ function isDuplicate(candidate: AdminCandidateItem) {
|
||||
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
|
||||
}
|
||||
|
||||
function identityKey(candidate: AdminCandidateItem) {
|
||||
if (typeof candidate.streamerIdentityId === 'number') {
|
||||
return `identity:${candidate.streamerIdentityId}`
|
||||
}
|
||||
|
||||
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
|
||||
}
|
||||
|
||||
function acceptanceLabel(value: string) {
|
||||
return props.acceptanceStatusOptions.find((option) => option.value === value)?.label ?? 'Offen'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
size="lg"
|
||||
:title="title"
|
||||
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season."
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input
|
||||
:value="form.groupName"
|
||||
type="text"
|
||||
class="h-12 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. Gaming"
|
||||
@input="form.groupName = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||
<input
|
||||
:value="form.sortOrder"
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
class="h-12 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"
|
||||
@input="form.sortOrder = toPositiveInteger(($event.target as HTMLInputElement).value, 1)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierungs-Limit</span>
|
||||
<input
|
||||
:value="form.maxNomineesPerUser"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
class="h-12 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"
|
||||
@input="form.maxNomineesPerUser = toPositiveInteger(($event.target as HTMLInputElement).value, 3)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||
<textarea
|
||||
:value="form.description"
|
||||
class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Kurze Orientierung fuer Admins und Public-Flows"
|
||||
@input="form.description = ($event.target as HTMLTextAreaElement).value"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="!canCreateGroup && !isEditing" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
Lege zuerst globale Unterkategorien an, bevor du neue Hauptkategorien erzeugst.
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
v-if="isEditing"
|
||||
variant="ghost"
|
||||
class="mr-auto gap-2 border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Loeschen
|
||||
</Button>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button :disabled="!canSave || saving" @click="$emit('save')">
|
||||
{{ saving ? 'Speichert ...' : isEditing ? 'Hauptkategorie speichern' : 'Hauptkategorie anlegen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Trash2 } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
form: {
|
||||
groupName: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}
|
||||
canSave: boolean
|
||||
canCreateGroup: boolean
|
||||
saving: boolean
|
||||
isEditing: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
function toPositiveInteger(value: string, fallback: number) {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? Math.max(1, Math.round(number)) : fallback
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<Modal :open="open" size="xl" :title="title" :subtitle="subtitle" @close="$emit('close')">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-500">Unterkategorien</p>
|
||||
<strong class="mt-2 block text-2xl text-violet-900">{{ categories.length }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-emerald-100 bg-emerald-50/70 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-600">Kandidaten</p>
|
||||
<strong class="mt-2 block text-2xl text-emerald-800">{{ candidates.length }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-amber-600">Nominierungen</p>
|
||||
<strong class="mt-2 block text-2xl text-amber-800">{{ nominations.length }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-sky-100 bg-sky-50/70 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-sky-600">Nominierer</p>
|
||||
<strong class="mt-2 block text-2xl text-sky-800">{{ totalNominatorCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="readinessItems.length > 0" class="mt-4 grid gap-2">
|
||||
<article
|
||||
v-for="item in readinessItems"
|
||||
:key="item.category.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border px-4 py-3"
|
||||
:class="item.isReady ? 'border-emerald-100 bg-emerald-50/70' : 'border-sky-100 bg-sky-50/70'"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold" :class="item.isReady ? 'text-emerald-900' : 'text-sky-900'">
|
||||
{{ item.category.groupName }} · {{ item.category.name }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs" :class="item.isReady ? 'text-emerald-700' : 'text-sky-700'">
|
||||
{{ item.nominatorCount }} von {{ readinessTargetLabel }} Nominierern erreicht. Hinweis, kein Blocker.
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.isReady ? 'bg-emerald-100 text-emerald-700' : 'bg-white/80 text-sky-700'">
|
||||
{{ item.isReady ? 'Bereit' : 'Aufbau' }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-5 lg:grid-cols-[0.82fr_1.18fr]">
|
||||
<section class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Tree-Ausschnitt</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Aktuelle Struktur und Belegung.</p>
|
||||
</div>
|
||||
<article
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="rounded-2xl border border-violet-100 bg-white px-4 py-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||
<p class="mt-1 font-mono text-xs text-slate-400">/{{ category.slug }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
|
||||
{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">
|
||||
{{ category.candidateCount }} Kandidaten
|
||||
</span>
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">
|
||||
{{ category.pendingCount }} Reviews
|
||||
</span>
|
||||
<span class="rounded-full bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||
{{ category.nominatorCount }}/{{ readinessTargetLabel }} Nominierer
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="space-y-5">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Nominierungs-Leaderboard</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Standard: viele Nominierungen vor wenigen Nominierungen.</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
{{ filteredLeaderboardRows.length }} Einträge
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 md:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
<label class="block">
|
||||
<span class="sr-only">Leaderboard filtern</span>
|
||||
<input
|
||||
v-model="leaderboardQuery"
|
||||
class="h-10 w-full rounded-2xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Streamer, Link oder Plattform filtern"
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
v-model="leaderboardStatusFilter"
|
||||
class="h-10 rounded-2xl border border-violet-200 bg-white px-3 text-sm font-semibold text-slate-600 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
<option value="pending">Offen</option>
|
||||
<option value="approved">Übernommen</option>
|
||||
<option value="rejected">Verworfen</option>
|
||||
<option value="candidate">Kandidaten</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="leaderboardSort"
|
||||
class="h-10 rounded-2xl border border-violet-200 bg-white px-3 text-sm font-semibold text-slate-600 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
>
|
||||
<option value="votes-desc">Viele Votes</option>
|
||||
<option value="votes-asc">Wenige Votes</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="recent-desc">Neueste</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredLeaderboardRows.length > 0" class="grid gap-2">
|
||||
<article
|
||||
v-for="(row, index) in filteredLeaderboardRows"
|
||||
:key="row.key"
|
||||
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)_auto] sm:items-center">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-sm font-black text-violet-700">
|
||||
#{{ index + 1 }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ row.label }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500">
|
||||
{{ row.platform || 'Plattform offen' }} · {{ row.url || 'kein Link' }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="status in row.statuses"
|
||||
:key="`${row.key}-${status}`"
|
||||
class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]"
|
||||
:class="statusBadgeClass(status)"
|
||||
>
|
||||
{{ statusLabel(status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:text-right">
|
||||
<strong class="block text-2xl text-violet-900">{{ row.voteCount }}</strong>
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.14em] text-violet-500">Nominierer</span>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ row.nominationCount }} Einreichungen</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
|
||||
Keine Leaderboard-Einträge passen zum Filter.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Kandidaten</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Finale und vorbereitete Kandidaten in diesem Ausschnitt.</p>
|
||||
</div>
|
||||
<div v-if="candidates.length > 0" class="grid gap-2">
|
||||
<article
|
||||
v-for="candidate in candidates"
|
||||
:key="candidate.id"
|
||||
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ candidate.displayName }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500">{{ candidate.platform }} · {{ candidate.channelSlug }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
|
||||
{{ candidate.nominationTally }}x
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
|
||||
Noch keine Kandidaten in diesem Ausschnitt.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Roh-Nominierungen</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Offene und bereits gepruefte Einreichungen zur Kontrolle.</p>
|
||||
</div>
|
||||
<div v-if="nominations.length > 0" class="grid gap-2">
|
||||
<article
|
||||
v-for="nomination in nominations"
|
||||
:key="nomination.id"
|
||||
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ nomination.resolvedChannel || nomination.candidateText || 'Ohne Namen' }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500">{{ nomination.streamUrl || nomination.categoryGroupName }}</p>
|
||||
</div>
|
||||
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="nomination.status === 'pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600'">
|
||||
{{ nomination.status }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-5 text-sm text-slate-500">
|
||||
Keine Nominierungen in diesem Ausschnitt.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Schliessen</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import type { AdminCategoryGroupSummary } from './useAdminCategoryManager'
|
||||
import { formatViewerRange } from './useAdminCategoryManager'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
subtitle: string
|
||||
categories: AdminCategoryGroupSummary['categories']
|
||||
candidates: AdminCandidateItem[]
|
||||
nominations: AdminNominationReviewItem[]
|
||||
recommendedNominatorTarget: number | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
type LeaderboardStatus = 'pending' | 'approved' | 'rejected' | 'candidate' | string
|
||||
type LeaderboardStatusFilter = 'all' | 'pending' | 'approved' | 'rejected' | 'candidate'
|
||||
type LeaderboardSort = 'votes-desc' | 'votes-asc' | 'name-asc' | 'recent-desc'
|
||||
|
||||
interface LeaderboardRow {
|
||||
key: string
|
||||
label: string
|
||||
platform: string
|
||||
url: string
|
||||
voteCount: number
|
||||
nominationCount: number
|
||||
latestCreatedAt: string
|
||||
statuses: LeaderboardStatus[]
|
||||
nominatorKeys: string[]
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const leaderboardQuery = ref('')
|
||||
const leaderboardStatusFilter = ref<LeaderboardStatusFilter>('all')
|
||||
const leaderboardSort = ref<LeaderboardSort>('votes-desc')
|
||||
|
||||
const totalNominatorCount = computed(() => uniqueNominatorCount(props.nominations))
|
||||
const readinessTargetLabel = computed(() => props.recommendedNominatorTarget === null ? '' : String(props.recommendedNominatorTarget))
|
||||
|
||||
const readinessItems = computed(() =>
|
||||
props.recommendedNominatorTarget === null
|
||||
? []
|
||||
: props.categories.map((category) => {
|
||||
const target = props.recommendedNominatorTarget ?? 0
|
||||
const nominations = props.nominations.filter((nomination) =>
|
||||
(nomination.categoryId !== null && nomination.categoryId === category.id)
|
||||
|| (nomination.suggestedCategoryId !== null && nomination.suggestedCategoryId === category.id),
|
||||
)
|
||||
const nominatorCount = uniqueNominatorCount(nominations)
|
||||
return {
|
||||
category,
|
||||
nominatorCount,
|
||||
isReady: nominatorCount >= target,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const leaderboardRows = computed<LeaderboardRow[]>(() => {
|
||||
const rows = new Map<string, LeaderboardRow>()
|
||||
|
||||
for (const nomination of props.nominations) {
|
||||
const key = nominationKey(nomination)
|
||||
const existing = rows.get(key)
|
||||
const label = nomination.resolvedChannel || nomination.candidateDisplayName || nomination.candidateText || nomination.streamUrl || 'Ohne Namen'
|
||||
const platform = nomination.resolvedPlatform || ''
|
||||
const url = nomination.streamUrl || ''
|
||||
const status = nomination.status || 'pending'
|
||||
const createdAt = nomination.createdAt || ''
|
||||
const row = existing ?? {
|
||||
key,
|
||||
label,
|
||||
platform,
|
||||
url,
|
||||
voteCount: 0,
|
||||
nominationCount: 0,
|
||||
latestCreatedAt: createdAt,
|
||||
statuses: [],
|
||||
nominatorKeys: [],
|
||||
searchText: '',
|
||||
}
|
||||
|
||||
row.nominationCount += 1
|
||||
const nominatorKey = nomination.submittedByTwitchId.trim().toLowerCase()
|
||||
if (nominatorKey && !row.nominatorKeys.includes(nominatorKey)) {
|
||||
row.nominatorKeys.push(nominatorKey)
|
||||
}
|
||||
row.voteCount = Math.max(row.voteCount, row.nominatorKeys.length)
|
||||
if (!row.statuses.includes(status)) row.statuses.push(status)
|
||||
if (createdAt && (!row.latestCreatedAt || createdAt > row.latestCreatedAt)) row.latestCreatedAt = createdAt
|
||||
row.searchText = [row.label, row.platform, row.url, row.statuses.join(' ')].join(' ').toLowerCase()
|
||||
rows.set(key, row)
|
||||
}
|
||||
|
||||
for (const candidate of props.candidates) {
|
||||
const key = candidateKey(candidate)
|
||||
const existing = rows.get(key)
|
||||
const row = existing ?? {
|
||||
key,
|
||||
label: candidate.displayName,
|
||||
platform: candidate.platform,
|
||||
url: candidate.channelSlug,
|
||||
voteCount: 0,
|
||||
nominationCount: 0,
|
||||
latestCreatedAt: '',
|
||||
statuses: [],
|
||||
nominatorKeys: [],
|
||||
searchText: '',
|
||||
}
|
||||
|
||||
row.label = row.label || candidate.displayName
|
||||
row.platform = row.platform || candidate.platform
|
||||
row.url = row.url || candidate.channelSlug
|
||||
row.voteCount = Math.max(row.voteCount, candidate.nominationTally)
|
||||
if (!row.statuses.includes('candidate')) row.statuses.push('candidate')
|
||||
row.searchText = [row.label, row.platform, row.url, row.statuses.join(' ')].join(' ').toLowerCase()
|
||||
rows.set(key, row)
|
||||
}
|
||||
|
||||
return [...rows.values()]
|
||||
})
|
||||
|
||||
const filteredLeaderboardRows = computed(() => {
|
||||
const search = leaderboardQuery.value.trim().toLowerCase()
|
||||
const statusFilter = leaderboardStatusFilter.value
|
||||
const filtered = leaderboardRows.value.filter((row) =>
|
||||
(!search || row.searchText.includes(search))
|
||||
&& (statusFilter === 'all' || row.statuses.includes(statusFilter)),
|
||||
)
|
||||
|
||||
return filtered.sort((left, right) => {
|
||||
if (leaderboardSort.value === 'votes-asc') return left.voteCount - right.voteCount || left.nominationCount - right.nominationCount || left.label.localeCompare(right.label, 'de')
|
||||
if (leaderboardSort.value === 'name-asc') return left.label.localeCompare(right.label, 'de')
|
||||
if (leaderboardSort.value === 'recent-desc') return right.latestCreatedAt.localeCompare(left.latestCreatedAt) || right.voteCount - left.voteCount || right.nominationCount - left.nominationCount
|
||||
return right.voteCount - left.voteCount || right.nominationCount - left.nominationCount || left.label.localeCompare(right.label, 'de')
|
||||
})
|
||||
})
|
||||
|
||||
function nominationKey(nomination: AdminNominationReviewItem) {
|
||||
if (nomination.streamerIdentityId !== null) return `identity:${nomination.streamerIdentityId}`
|
||||
const channel = (nomination.resolvedChannel || nomination.candidateText || '').trim().toLowerCase()
|
||||
if (channel) return `channel:${channel}`
|
||||
const url = (nomination.streamUrl || '').trim().toLowerCase()
|
||||
if (url) return `url:${url}`
|
||||
return `nomination:${nomination.id}`
|
||||
}
|
||||
|
||||
function candidateKey(candidate: AdminCandidateItem) {
|
||||
if (candidate.streamerIdentityId !== null) return `identity:${candidate.streamerIdentityId}`
|
||||
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||
if (channel) return `channel:${channel}`
|
||||
return `candidate:${candidate.id}`
|
||||
}
|
||||
|
||||
function uniqueNominatorCount(nominations: AdminNominationReviewItem[]) {
|
||||
return new Set(nominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean)).size
|
||||
}
|
||||
|
||||
function statusLabel(status: LeaderboardStatus) {
|
||||
if (status === 'pending') return 'offen'
|
||||
if (status === 'approved') return 'übernommen'
|
||||
if (status === 'rejected') return 'verworfen'
|
||||
if (status === 'candidate') return 'Kandidat'
|
||||
return status
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: LeaderboardStatus) {
|
||||
if (status === 'pending') return 'bg-amber-50 text-amber-700'
|
||||
if (status === 'approved' || status === 'candidate') return 'bg-emerald-50 text-emerald-700'
|
||||
if (status === 'rejected') return 'bg-rose-50 text-rose-700'
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<Modal :open="open" size="lg" :title="title" subtitle="Diese Unterkategorie wird spaeter bei allen Hauptkategorien verwendet." @close="$emit('close')">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input
|
||||
:value="form.name"
|
||||
type="text"
|
||||
class="h-12 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. Rising Star"
|
||||
@input="form.name = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:value="form.slug"
|
||||
type="text"
|
||||
class="h-12 min-w-0 flex-1 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="rising-star"
|
||||
@input="form.slug = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<Button variant="ghost" class="border border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100" @click="$emit('fill-slug')">
|
||||
Auto
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer von</span>
|
||||
<input
|
||||
:value="form.viewerRangeMin ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-12 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="0"
|
||||
@input="form.viewerRangeMin = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer bis</span>
|
||||
<input
|
||||
:value="form.viewerRangeMax ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-12 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="offen"
|
||||
@input="form.viewerRangeMax = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||
<input
|
||||
:value="form.sortOrder"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-12 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"
|
||||
@input="form.sortOrder = Math.max(1, Number(($event.target as HTMLInputElement).value) || 1)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button :disabled="!canSave" @click="$emit('save')">Unterkategorie speichern</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import type { AdminSubcategoryDraft } from './useAdminCategoryManager'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
form: AdminSubcategoryDraft
|
||||
canSave: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
'fill-slug': []
|
||||
}>()
|
||||
|
||||
function toNullableNumber(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const number = Number(trimmed)
|
||||
return Number.isFinite(number) ? number : null
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
size="xl"
|
||||
title="Unterkategorien konfigurieren"
|
||||
subtitle="Diese Viewer-Sektionen gelten fuer alle Hauptkategorien der ausgewaehlten Season."
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Globale Unterkategorien</p>
|
||||
<p class="text-sm text-slate-500">Die Hauptkategorien muessen sie nicht einzeln anlegen.</p>
|
||||
</div>
|
||||
<Button class="gap-2" @click="$emit('add-subcategory')">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Unterkategorie
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="subcategories.length > 0" class="grid gap-3">
|
||||
<article
|
||||
v-for="(subcategory, index) in subcategories"
|
||||
:key="`${subcategory.slug}-${index}`"
|
||||
class="rounded-2xl border border-violet-100 bg-white px-4 py-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-bold text-slate-900">{{ subcategory.name || 'Neue Unterkategorie' }}</h3>
|
||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[11px] font-semibold text-violet-700">
|
||||
{{ formatViewerRange(subcategory.viewerRangeMin, subcategory.viewerRangeMax) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs text-slate-400">/{{ subcategory.slug || 'slug-fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-slate-600">
|
||||
#{{ subcategory.sortOrder }}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" class="gap-2" @click="$emit('edit-subcategory', index)">
|
||||
<Pencil class="h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="gap-2 border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100" @click="$emit('remove-subcategory', index)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-4 py-8 text-center text-sm text-slate-500">
|
||||
Noch keine Unterkategorien vorhanden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||
<div v-if="editorOpen" class="space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ editorTitle }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Aenderungen werden erst mit der gesamten Unterkategorien-Konfiguration gespeichert.</p>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input
|
||||
:value="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"
|
||||
placeholder="z. B. Rising Star"
|
||||
@input="form.name = ($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">Slug</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:value="form.slug"
|
||||
type="text"
|
||||
class="h-12 min-w-0 flex-1 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="rising-star"
|
||||
@input="form.slug = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<Button variant="ghost" class="border border-violet-200 bg-white text-violet-700 hover:bg-violet-50" @click="$emit('fill-slug')">
|
||||
Auto
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer von</span>
|
||||
<input
|
||||
:value="form.viewerRangeMin ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
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="0"
|
||||
@input="form.viewerRangeMin = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Viewer bis</span>
|
||||
<input
|
||||
:value="form.viewerRangeMax ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
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="offen"
|
||||
@input="form.viewerRangeMax = toNullableNumber(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
|
||||
<input
|
||||
:value="form.sortOrder"
|
||||
type="number"
|
||||
min="1"
|
||||
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"
|
||||
@input="form.sortOrder = Math.max(1, Number(($event.target as HTMLInputElement).value) || 1)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="$emit('close-editor')">Abbrechen</Button>
|
||||
<Button :disabled="!canSaveSubcategory" @click="$emit('save-subcategory')">
|
||||
Unterkategorie uebernehmen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed border-violet-200 bg-white/60 px-4 py-8 text-center">
|
||||
<Layers3 class="h-8 w-8 text-violet-300" />
|
||||
<p class="mt-3 font-semibold text-slate-700">Unterkategorie auswaehlen</p>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">Bearbeite eine bestehende Sektion oder lege eine neue Viewer-Sektion an.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button :disabled="!canSave || saving" @click="$emit('save')">
|
||||
{{ saving ? 'Speichert ...' : 'Unterkategorien speichern' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Layers3, Pencil, PlusCircle, Trash2 } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import type { AdminSubcategoryDraft } from './useAdminCategoryManager'
|
||||
import { formatViewerRange } from './useAdminCategoryManager'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
subcategories: AdminSubcategoryDraft[]
|
||||
canSave: boolean
|
||||
saving: boolean
|
||||
editorOpen: boolean
|
||||
editorTitle: string
|
||||
form: AdminSubcategoryDraft
|
||||
canSaveSubcategory: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
'add-subcategory': []
|
||||
'edit-subcategory': [index: number]
|
||||
'remove-subcategory': [index: number]
|
||||
'close-editor': []
|
||||
'save-subcategory': []
|
||||
'fill-slug': []
|
||||
}>()
|
||||
|
||||
function toNullableNumber(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const number = Number(trimmed)
|
||||
return Number.isFinite(number) ? number : null
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<article
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
class="overflow-hidden rounded-2xl border bg-white/90 shadow-[0_14px_36px_rgba(124,91,180,0.08)]"
|
||||
:class="selectedGroupName === group.name ? 'border-violet-200 ring-2 ring-violet-100' : 'border-violet-100'"
|
||||
>
|
||||
<div class="grid gap-3 border-b border-violet-100 bg-violet-50/45 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<button type="button" class="min-w-0 text-left" @click="$emit('edit-group', group)">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl bg-white text-violet-700 shadow-sm">
|
||||
<FolderTree class="h-4 w-4" />
|
||||
</span>
|
||||
<h2 class="truncate text-lg font-bold text-slate-900">{{ group.name }}</h2>
|
||||
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">
|
||||
#{{ group.sortOrder }}
|
||||
</span>
|
||||
<span class="rounded-full bg-white px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-600">
|
||||
Limit {{ group.maxNomineesPerUser }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-slate-500">
|
||||
{{ group.description || 'Keine Beschreibung hinterlegt.' }}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
{{ group.candidateCount }} Kandidaten
|
||||
</span>
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
{{ group.pendingCount }} Reviews
|
||||
</span>
|
||||
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
|
||||
{{ group.nominatorCount }} Nominierer
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" class="gap-2" @click.stop="$emit('overview-group', group)">
|
||||
<Info class="h-4 w-4" />
|
||||
Details
|
||||
</Button>
|
||||
<Button size="sm" class="gap-2" @click.stop="$emit('edit-group', group)">
|
||||
<Pencil class="h-4 w-4" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-violet-100">
|
||||
<div
|
||||
v-for="category in group.categories"
|
||||
:key="category.id"
|
||||
class="grid gap-3 px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"
|
||||
:class="isFocused(category.slug) || isFocused(category.name) ? 'bg-sky-50/60' : 'bg-white'"
|
||||
>
|
||||
<button type="button" class="min-w-0 border-l-2 border-violet-100 pl-4 text-left" @click="$emit('overview-category', group, category)">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-violet-50 text-violet-600">
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||
<span
|
||||
v-if="isFocused(category.slug) || isFocused(category.name)"
|
||||
class="rounded-full bg-sky-100 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700"
|
||||
>
|
||||
Fokus
|
||||
</span>
|
||||
<span class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="statusClass(category)">
|
||||
{{ statusLabel(category) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
<span class="font-mono text-slate-400">/{{ category.slug }}</span>
|
||||
<span>{{ formatViewerRange(category.viewerRangeMin, category.viewerRangeMax) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
{{ category.candidateCount }} Kandidaten
|
||||
</span>
|
||||
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
{{ category.pendingCount }} Reviews
|
||||
</span>
|
||||
<span class="rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700">
|
||||
{{ category.nominatorCount }} Nominierer<span v-if="recommendedNominatorTarget !== null"> · Ziel {{ recommendedNominatorTarget }}</span>
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" class="gap-2" @click="$emit('overview-category', group, category)">
|
||||
<Info class="h-4 w-4" />
|
||||
Overview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="groups.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/50 px-4 py-12 text-center text-sm text-slate-500">
|
||||
Keine Hauptkategorien passen zu diesem Filter.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderTree, GitBranch, Info, Pencil } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminCategoryGroupSummary } from './useAdminCategoryManager'
|
||||
import { formatViewerRange } from './useAdminCategoryManager'
|
||||
|
||||
type TreeCategory = AdminCategoryGroupSummary['categories'][number]
|
||||
|
||||
defineProps<{
|
||||
groups: AdminCategoryGroupSummary[]
|
||||
selectedGroupName: string | null
|
||||
isFocused: (nameOrSlug: string) => boolean
|
||||
recommendedNominatorTarget: number | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'edit-group': [group: AdminCategoryGroupSummary]
|
||||
'overview-group': [group: AdminCategoryGroupSummary]
|
||||
'overview-category': [group: AdminCategoryGroupSummary, category: TreeCategory]
|
||||
}>()
|
||||
|
||||
function statusLabel(category: TreeCategory) {
|
||||
if (category.pendingCount > 0) return 'Reviews'
|
||||
if (category.nominatorCount >= 4) return 'Bereit'
|
||||
if (category.nominatorCount > 0 || category.candidateCount > 0) return 'Aufbau'
|
||||
if (category.candidateCount === 0) return 'Leer'
|
||||
return 'Aufbau'
|
||||
}
|
||||
|
||||
function statusClass(category: TreeCategory) {
|
||||
if (category.pendingCount > 0) return 'bg-amber-50 text-amber-700'
|
||||
if (category.nominatorCount >= 4) return 'bg-emerald-50 text-emerald-700'
|
||||
if (category.nominatorCount > 0 || category.candidateCount > 0) return 'bg-sky-50 text-sky-700'
|
||||
if (category.candidateCount === 0) return 'bg-rose-50 text-rose-700'
|
||||
return 'bg-sky-50 text-sky-700'
|
||||
}
|
||||
</script>
|
||||
@@ -29,6 +29,14 @@
|
||||
<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>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Auf X teilen – URL</span>
|
||||
<input v-model="form.shareXUrl" type="url" placeholder="https://x.com/intent/tweet?..." 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">Auf Discord teilen – URL</span>
|
||||
<input v-model="form.shareDiscordUrl" type="url" placeholder="https://discord.gg/..." 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>
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
|
||||
import { Eye, Handshake, Mic2, Plus, Save, Settings2, Trash2 } from '@lucide/vue'
|
||||
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminSponsorItem, UpsertSponsorPayload } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import AdminContentFooterPreviewModal from './AdminContentFooterPreviewModal.vue'
|
||||
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
import AdminShowactFormBuilder from './AdminShowactFormBuilder.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
section: 'showacts' | 'sponsors'
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const store = useAwardsStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const localSaving = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusError = ref('')
|
||||
const previewOpen = ref(false)
|
||||
const previewHtml = computed(() => privacyContentToHtml(props.form.sponsorsContent))
|
||||
const previewTitle = 'Sponsoren & Partner'
|
||||
const editingSponsorId = ref<number | null>(null)
|
||||
const selectedShowactId = ref<number | null>(null)
|
||||
const showactNotes = reactive<Record<number, string>>({})
|
||||
const settingsForm = reactive({
|
||||
showactApplicationsEnabled: false,
|
||||
showactApplicationStartsAt: '',
|
||||
showactApplicationEndsAt: '',
|
||||
showactApplicationsOpenNow: false,
|
||||
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
|
||||
sponsorsVisible: true,
|
||||
})
|
||||
@@ -33,17 +52,108 @@ const sponsorForm = reactive<UpsertSponsorPayload>({
|
||||
const seasonId = computed(() => store.adminSelectedSeasonId ?? (store.adminSeasonDetail.id || store.overview.seasonId))
|
||||
const pendingShowacts = computed(() => store.adminShowactApplications.filter((item) => item.status === 'pending').length)
|
||||
const visibleSponsors = computed(() => store.adminSponsors.filter((item) => item.isVisible).length)
|
||||
const selectedShowact = computed(() =>
|
||||
selectedShowactId.value == null
|
||||
? null
|
||||
: store.adminShowactApplications.find((item) => item.id === selectedShowactId.value) ?? null,
|
||||
)
|
||||
|
||||
const showactSubmissionDetails = computed(() => {
|
||||
const application = selectedShowact.value
|
||||
if (!application) return []
|
||||
|
||||
const schemaFields = props.form.showactFormSchema ?? []
|
||||
const responseEntries = parseShowactResponses(application.fieldResponsesJson)
|
||||
const responseMap = new Map(responseEntries)
|
||||
const consumedKeys = new Set<string>()
|
||||
|
||||
const orderedFields = schemaFields
|
||||
.map((field) => {
|
||||
const value = normalizeDetailValue(responseMap.get(field.id))
|
||||
consumedKeys.add(field.id)
|
||||
if (!value) return null
|
||||
return {
|
||||
key: field.id,
|
||||
label: field.label,
|
||||
value,
|
||||
}
|
||||
})
|
||||
.filter((item): item is { key: string; label: string; value: string } => item !== null)
|
||||
|
||||
const remainingFields = responseEntries
|
||||
.filter(([key]) => !consumedKeys.has(key))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: humanizeShowactKey(key),
|
||||
value: normalizeDetailValue(value),
|
||||
}))
|
||||
.filter((item) => item.value)
|
||||
|
||||
if (orderedFields.length > 0 || remainingFields.length > 0) {
|
||||
return [...orderedFields, ...remainingFields]
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: 'artistName', label: 'Artist', value: application.artistName },
|
||||
{ key: 'performanceType', label: 'Performance', value: application.performanceType },
|
||||
{ key: 'description', label: 'Beschreibung', value: application.description },
|
||||
{ key: 'technicalNotes', label: 'Technische Hinweise', value: application.technicalNotes },
|
||||
{ key: 'platformUrl', label: 'Kanal oder Profil', value: application.platformUrl },
|
||||
{ key: 'referenceUrl', label: 'Referenz', value: application.referenceUrl },
|
||||
{ key: 'contactEmail', label: 'E-Mail', value: application.contactEmail },
|
||||
{ key: 'contactDiscord', label: 'Discord', value: application.contactDiscord },
|
||||
].filter((item) => normalizeDetailValue(item.value))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.adminOptionalFeatureSettings,
|
||||
(settings) => {
|
||||
settingsForm.showactApplicationsEnabled = settings.showactApplicationsEnabled
|
||||
settingsForm.showactApplicationStartsAt = settings.showactApplicationStartsAt ?? ''
|
||||
settingsForm.showactApplicationEndsAt = settings.showactApplicationEndsAt ?? ''
|
||||
settingsForm.showactApplicationsOpenNow = settings.showactApplicationsOpenNow
|
||||
settingsForm.showactApplicationDisabledMessage = settings.showactApplicationDisabledMessage || 'Showact-Bewerbungen sind aktuell geschlossen.'
|
||||
settingsForm.sponsorsVisible = settings.sponsorsVisible
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const showactStatusText = computed(() => {
|
||||
if (!settingsForm.showactApplicationsEnabled) {
|
||||
return 'Bewerbungen sind manuell deaktiviert und es wird nur der Hinweistext ausgespielt.'
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (settingsForm.showactApplicationStartsAt) {
|
||||
parts.push(`Start ${formatDate(settingsForm.showactApplicationStartsAt)}`)
|
||||
}
|
||||
if (settingsForm.showactApplicationEndsAt) {
|
||||
parts.push(`Deadline ${formatDate(settingsForm.showactApplicationEndsAt)}`)
|
||||
}
|
||||
|
||||
if (settingsForm.showactApplicationsOpenNow) {
|
||||
return parts.length > 0
|
||||
? `Bewerbungen sind aktuell offen. ${parts.join(' · ')}.`
|
||||
: 'Bewerbungen sind aktuell offen und das Modal bleibt fuer Einreichungen verfuegbar.'
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return `Bewerbungen sind aktuell geschlossen. ${parts.join(' · ')}.`
|
||||
}
|
||||
|
||||
return 'Bewerbungen sind aktuell geschlossen und es wird nur der Hinweistext ausgespielt.'
|
||||
})
|
||||
|
||||
const showactScheduleError = computed(() => {
|
||||
if (!settingsForm.showactApplicationStartsAt || !settingsForm.showactApplicationEndsAt) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return settingsForm.showactApplicationStartsAt > settingsForm.showactApplicationEndsAt
|
||||
? 'Der Starttermin darf nicht nach der Deadline liegen.'
|
||||
: ''
|
||||
})
|
||||
|
||||
function resetSponsorForm() {
|
||||
editingSponsorId.value = null
|
||||
sponsorForm.name = ''
|
||||
@@ -92,31 +202,41 @@ async function loadLandingExtras() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLandingExtrasSettings() {
|
||||
saving.value = true
|
||||
async function saveAll() {
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
await store.updateAdminOptionalFeatureSettings({
|
||||
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
|
||||
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
|
||||
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
|
||||
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
|
||||
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
|
||||
sponsorsVisible: settingsForm.sponsorsVisible,
|
||||
})
|
||||
statusMessage.value = 'Landingpage-Extras wurden gespeichert.'
|
||||
if (showactScheduleError.value) {
|
||||
throw new Error(showactScheduleError.value)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
store.updateAdminOptionalFeatureSettings({
|
||||
clipSubmissionsEnabled: store.adminOptionalFeatureSettings.clipSubmissionsEnabled,
|
||||
clipReviewEnabled: store.adminOptionalFeatureSettings.clipReviewEnabled,
|
||||
clipAdminMenuVisible: store.adminOptionalFeatureSettings.clipAdminMenuVisible,
|
||||
clipSubmissionDisabledMessage: store.adminOptionalFeatureSettings.clipSubmissionDisabledMessage,
|
||||
showactApplicationsEnabled: settingsForm.showactApplicationsEnabled,
|
||||
showactApplicationStartsAt: settingsForm.showactApplicationStartsAt || null,
|
||||
showactApplicationEndsAt: settingsForm.showactApplicationEndsAt || null,
|
||||
showactApplicationDisabledMessage: settingsForm.showactApplicationDisabledMessage,
|
||||
sponsorsVisible: settingsForm.sponsorsVisible,
|
||||
}),
|
||||
props.saveSiteSettings(props.section === 'showacts' ? 'Showact-Inhalt' : 'Sponsoren-Inhalt'),
|
||||
])
|
||||
statusMessage.value = 'Einstellungen gespeichert.'
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Landingpage-Extras konnten nicht gespeichert werden.'
|
||||
statusError.value = error instanceof Error ? error.message : 'Einstellungen konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSponsor() {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -131,14 +251,14 @@ async function saveSponsor() {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSponsor(sponsor: AdminSponsorItem) {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId || !window.confirm(`Sponsor "${sponsor.name}" loeschen?`)) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -148,14 +268,14 @@ async function deleteSponsor(sponsor: AdminSponsorItem) {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Sponsor konnte nicht geloescht werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateShowactStatus(applicationId: number, status: string) {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -167,14 +287,14 @@ async function updateShowactStatus(applicationId: number, status: string) {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Showact-Status konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShowact(applicationId: number, artistName: string) {
|
||||
const resolvedSeasonId = await ensureSeasonId()
|
||||
if (!resolvedSeasonId || !window.confirm(`Showact-Bewerbung von "${artistName}" loeschen?`)) return
|
||||
saving.value = true
|
||||
localSaving.value = true
|
||||
statusMessage.value = ''
|
||||
statusError.value = ''
|
||||
try {
|
||||
@@ -184,7 +304,7 @@ async function deleteShowact(applicationId: number, artistName: string) {
|
||||
} catch (error) {
|
||||
statusError.value = error instanceof Error ? error.message : 'Showact-Bewerbung konnte nicht geloescht werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
localSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,97 +317,151 @@ function statusLabel(status: string) {
|
||||
}[status] ?? status
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const parsed = new Date(`${value}T00:00:00`)
|
||||
return Number.isNaN(parsed.getTime())
|
||||
? value
|
||||
: parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function parseShowactResponses(raw: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [] as Array<[string, unknown]>
|
||||
}
|
||||
|
||||
return Object.entries(parsed)
|
||||
} catch {
|
||||
return [] as Array<[string, unknown]>
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDetailValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Ja' : 'Nein'
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
function humanizeShowactKey(key: string) {
|
||||
return key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^./, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function openShowactDetails(applicationId: number) {
|
||||
selectedShowactId.value = applicationId
|
||||
}
|
||||
|
||||
function closeShowactDetails() {
|
||||
selectedShowactId.value = null
|
||||
}
|
||||
|
||||
onMounted(loadLandingExtras)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card id="content-landing-extras" class="p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Landingpage Extras</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Showacts und Sponsoren</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Module, Bewerbungen und Sponsoren direkt dort steuern, wo sie public erscheinen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
<template v-if="props.section === 'showacts'">
|
||||
{{ store.adminShowactApplications.length }} Bewerbungen · {{ pendingShowacts }} offen
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ store.adminSponsors.length }} Sponsoren · {{ visibleSponsors }} sichtbar
|
||||
</template>
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" :disabled="loading" @click="loadLandingExtras">
|
||||
<Settings2 class="h-4 w-4" />
|
||||
{{ loading ? 'Laedt ...' : 'Neu laden' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-fuchsia-100 bg-fuchsia-50/55 p-4">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.18em] text-fuchsia-600">Showacts</p>
|
||||
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminShowactApplications.length }}</p>
|
||||
<p class="text-sm text-slate-500">{{ pendingShowacts }} offen</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-sky-100 bg-sky-50/55 p-4">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.18em] text-sky-600">Sponsoren</p>
|
||||
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.adminSponsors.length }}</p>
|
||||
<p class="text-sm text-slate-500">{{ visibleSponsors }} sichtbar</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/55 p-4">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-600">Saison</p>
|
||||
<p class="mt-2 text-2xl font-black text-slate-950">{{ store.overview.year || store.adminSeasonDetail.year || '–' }}</p>
|
||||
<p class="text-sm text-slate-500">Landingpage-Jahr</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mt-6 grid gap-3 lg:grid-cols-2">
|
||||
<!-- Showacts section -->
|
||||
<template v-if="props.section === 'showacts'">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Showact-Bewerbung</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">Public-Formular auf der Landingpage anzeigen.</p>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
{{ showactStatusText }}
|
||||
</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="settingsForm.showactApplicationsEnabled"
|
||||
label="Showact-Bewerbungen aktivieren"
|
||||
active-label="Offen"
|
||||
inactive-label="Zu"
|
||||
active-label="Bewerbung offen"
|
||||
inactive-label="Bewerbung geschlossen"
|
||||
@update:model-value="settingsForm.showactApplicationsEnabled = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="settingsForm.sponsorsVisible"
|
||||
label="Sponsoren anzeigen"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Aus"
|
||||
@update:model-value="settingsForm.sponsorsVisible = $event"
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Starttermin (optional)</span>
|
||||
<input
|
||||
v-model="settingsForm.showactApplicationStartsAt"
|
||||
type="date"
|
||||
class="h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-sm text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Deadline (optional)</span>
|
||||
<input
|
||||
v-model="settingsForm.showactApplicationEndsAt"
|
||||
type="date"
|
||||
class="h-12 w-full rounded-2xl border border-violet-100 bg-white px-4 text-sm text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="mt-4 block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Showact-Bewerbungen</span>
|
||||
<textarea
|
||||
v-model="settingsForm.showactApplicationDisabledMessage"
|
||||
rows="3"
|
||||
maxlength="240"
|
||||
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
<p v-if="showactScheduleError" class="text-sm font-semibold text-rose-700">
|
||||
{{ showactScheduleError }}
|
||||
</p>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Hinweis bei geschlossenen Bewerbungen</span>
|
||||
<textarea
|
||||
v-model="settingsForm.showactApplicationDisabledMessage"
|
||||
rows="3"
|
||||
maxlength="240"
|
||||
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button class="gap-2" :disabled="props.saving || localSaving" @click="saveAll">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ (props.saving || localSaving) ? 'Speichert ...' : 'Einstellungen speichern' }}
|
||||
</Button>
|
||||
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
|
||||
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
|
||||
</div>
|
||||
|
||||
<AdminShowactFormBuilder
|
||||
:form="props.form"
|
||||
:saving="props.saving"
|
||||
:save-site-settings="props.saveSiteSettings"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<Button class="gap-2" :disabled="saving" @click="saveLandingExtrasSettings">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Landingpage Extras speichern' }}
|
||||
</Button>
|
||||
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
|
||||
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
|
||||
</div>
|
||||
|
||||
<section class="mt-7 grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)]">
|
||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
||||
<div class="border-b border-violet-100 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -309,7 +483,7 @@ onMounted(loadLandingExtras)
|
||||
<p class="mt-1 text-sm text-slate-500">{{ application.performanceType }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" :disabled="saving" @click="updateShowactStatus(application.id, 'shortlisted')">Shortlist</Button>
|
||||
<Button size="sm" variant="ghost" :disabled="saving" @click="openShowactDetails(application.id)">Details</Button>
|
||||
<Button size="sm" :disabled="saving" @click="updateShowactStatus(application.id, 'accepted')">Annehmen</Button>
|
||||
<Button size="sm" variant="secondary" :disabled="saving" @click="updateShowactStatus(application.id, 'rejected')">Ablehnen</Button>
|
||||
<Button size="sm" variant="ghost" class="text-rose-700" :disabled="saving" @click="deleteShowact(application.id, application.artistName)">
|
||||
@@ -317,13 +491,28 @@ onMounted(loadLandingExtras)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
|
||||
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
|
||||
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
|
||||
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
|
||||
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
|
||||
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic field responses if available, otherwise legacy fixed fields -->
|
||||
<template v-if="application.fieldResponsesJson && application.fieldResponsesJson !== '{}'">
|
||||
<div class="grid gap-2 text-sm text-slate-600 md:grid-cols-2">
|
||||
<template v-for="(value, key) in (() => { try { return JSON.parse(application.fieldResponsesJson) } catch { return {} } })()" :key="key">
|
||||
<div v-if="value" class="flex flex-col gap-0.5">
|
||||
<span class="text-xs font-bold uppercase tracking-wide text-slate-400">{{ key }}</span>
|
||||
<span>{{ value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm leading-6 text-slate-600">{{ application.description }}</p>
|
||||
<div class="grid gap-2 text-sm text-slate-500 md:grid-cols-2">
|
||||
<a v-if="application.platformUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.platformUrl" target="_blank" rel="noreferrer">Kanal/Profil</a>
|
||||
<a v-if="application.referenceUrl" class="font-semibold text-violet-700 hover:text-violet-900" :href="application.referenceUrl" target="_blank" rel="noreferrer">Referenz</a>
|
||||
<span v-if="application.contactEmail">Mail: {{ application.contactEmail }}</span>
|
||||
<span v-if="application.contactDiscord">Discord: {{ application.contactDiscord }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<textarea
|
||||
v-model="showactNotes[application.id]"
|
||||
rows="2"
|
||||
@@ -334,6 +523,47 @@ onMounted(loadLandingExtras)
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Sponsors section -->
|
||||
<template v-else>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Sponsoren-Modul</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">Sponsor-Kacheln auf der Landingpage anzeigen.</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="settingsForm.sponsorsVisible"
|
||||
label="Sponsoren anzeigen"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Aus"
|
||||
@update:model-value="settingsForm.sponsorsVisible = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex 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="previewOpen = true">
|
||||
<Eye class="h-4 w-4" />
|
||||
Preview öffnen
|
||||
</Button>
|
||||
</div>
|
||||
<AdminRichTextEditor
|
||||
v-model="props.form.sponsorsContent"
|
||||
label="Sponsoren-Seiteninhalt"
|
||||
placeholder="Texte für die Sponsoren-Seite (wird als Modal auf der Landingpage angezeigt)..."
|
||||
min-height-class="min-h-[220px]"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button class="gap-2" :disabled="props.saving || localSaving" @click="saveAll">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ (props.saving || localSaving) ? 'Speichert ...' : 'Einstellungen speichern' }}
|
||||
</Button>
|
||||
<p v-if="statusMessage" class="text-sm font-semibold text-emerald-700">{{ statusMessage }}</p>
|
||||
<p v-if="statusError" class="text-sm font-semibold text-rose-700">{{ statusError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
||||
<div class="border-b border-violet-100 p-4">
|
||||
@@ -388,6 +618,54 @@ onMounted(loadLandingExtras)
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<AdminContentFooterPreviewModal
|
||||
:open="previewOpen"
|
||||
:title="previewTitle"
|
||||
url=""
|
||||
:content-html="previewHtml"
|
||||
@close="previewOpen = false"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
:open="selectedShowact !== null"
|
||||
size="xl"
|
||||
:title="selectedShowact ? `${selectedShowact.artistName} · Showact-Details` : 'Showact-Details'"
|
||||
:subtitle="selectedShowact ? `${statusLabel(selectedShowact.status)} · Eingereicht am ${new Date(selectedShowact.createdAt).toLocaleString('de-DE')}` : undefined"
|
||||
@close="closeShowactDetails"
|
||||
>
|
||||
<div v-if="selectedShowact" class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Artist</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.artistName }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900">{{ statusLabel(selectedShowact.status) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">E-Mail</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.contactEmail || 'Nicht angegeben' }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Discord</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-900">{{ selectedShowact.contactDiscord || 'Nicht angegeben' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="detail in showactSubmissionDetails"
|
||||
:key="detail.key"
|
||||
class="rounded-2xl border border-violet-100 bg-white px-4 py-3"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ detail.label }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap break-words text-sm leading-6 text-slate-800">{{ detail.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex 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="previewOpen = true">
|
||||
<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 ...' : `${props.label} speichern` }}
|
||||
</Button>
|
||||
</div>
|
||||
<AdminRichTextEditor
|
||||
v-model="form[props.contentKey]"
|
||||
:label="props.label"
|
||||
:placeholder="props.placeholder"
|
||||
min-height-class="min-h-[420px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminContentFooterPreviewModal
|
||||
:open="previewOpen"
|
||||
:title="props.label"
|
||||
url=""
|
||||
:content-html="previewHtml"
|
||||
@close="previewOpen = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Eye, Save } from '@lucide/vue'
|
||||
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import Button from '../ui/Button.vue'
|
||||
import AdminContentFooterPreviewModal from './AdminContentFooterPreviewModal.vue'
|
||||
import AdminRichTextEditor from './AdminRichTextEditor.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
label: string
|
||||
placeholder: string
|
||||
contentKey: 'imprintContent' | 'contactContent' | 'sponsorsContent'
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewHtml = computed(() => privacyContentToHtml(props.form[props.contentKey]))
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings(props.label)
|
||||
}
|
||||
</script>
|
||||
@@ -31,9 +31,13 @@
|
||||
<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 E-Mail</span>
|
||||
<input v-model="form.privacyEmail" type="email" placeholder="datenschutz@..." 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>
|
||||
<AdminRichTextEditor
|
||||
v-model="form.privacyPolicyContent"
|
||||
class="mt-6"
|
||||
class="mt-4"
|
||||
label="Datenschutz Inhalt"
|
||||
placeholder="Datenschutztext..."
|
||||
min-height-class="min-h-[560px]"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound } from '@lucide/vue'
|
||||
|
||||
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||
import PasswordField from '../ui/PasswordField.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminOperationalSettingsForm
|
||||
saving: boolean
|
||||
canManage: boolean
|
||||
demoPassword: string
|
||||
demoPasswordHint: string
|
||||
demoPasswordSet: boolean
|
||||
demoManagedByDatabase: boolean
|
||||
demoCredentialsComplete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:demoPassword': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<PasswordField
|
||||
:model-value="demoPassword"
|
||||
:disabled="saving || !canManage"
|
||||
autocomplete="new-password"
|
||||
placeholder="Leer lassen = behalten"
|
||||
root-class="mt-2"
|
||||
input-class="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"
|
||||
@update:model-value="emit('update:demoPassword', $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>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ShieldCheck, Wrench } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminOperationalSettingsForm
|
||||
saving: boolean
|
||||
canManage: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
@@ -1,52 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted } 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'
|
||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
nomination: AdminNominationReviewGroup | null
|
||||
reviewSaving: number | null
|
||||
reviewForm: {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
categoryId: number | null
|
||||
reviewNote: string
|
||||
} | null
|
||||
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||
categoryOptions: Array<{ id: number; label: string }>
|
||||
selectedCandidateCollision: AdminCandidateItem | null
|
||||
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||
signalSummary?: {
|
||||
submissions: number
|
||||
uniqueSubmitters: number
|
||||
platforms: string[]
|
||||
} | null
|
||||
trackingReviewNotes?: string
|
||||
selectedNominationHasBlockingFlag?: boolean
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
blacklistSaving: boolean
|
||||
selectedPlatformValue: (platform: string) => string
|
||||
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'platform-change': [value: string]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
reviewFlagged: [nominationId: number]
|
||||
overrideFlagged: [nominationId: number]
|
||||
'blacklist-link': [streamUrl: string]
|
||||
}>()
|
||||
|
||||
const {
|
||||
reviewSaving,
|
||||
blacklistSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
addStreamUrlToBlacklist,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
} = useAdminReviewsManager()
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
const modalTitle = computed(() => props.nomination?.displayName || 'Nominierung entscheiden')
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
@@ -84,7 +83,7 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
<h2 class="mt-3 text-2xl font-bold text-slate-950">{{ modalTitle }}</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>
|
||||
@@ -99,83 +98,30 @@ onBeforeUnmount(() => {
|
||||
</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"
|
||||
<div class="min-h-0 overflow-y-auto p-4 sm:p-6">
|
||||
<AdminReviewDecisionPanel
|
||||
:nomination="nomination"
|
||||
:review-saving="reviewSaving"
|
||||
:review-form="reviewForm"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:category-options="categoryOptions"
|
||||
:selected-candidate-collision="selectedCandidateCollision"
|
||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||
:signal-summary="signalSummary"
|
||||
:tracking-review-notes="trackingReviewNotes"
|
||||
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
|
||||
:stream-url="streamUrl"
|
||||
:can-approve-selected="canApproveSelected"
|
||||
:blacklist-saving="blacklistSaving"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
:tracker-status-meta="trackerStatusMeta"
|
||||
@platform-change="emit('platform-change', $event)"
|
||||
@approve="emit('approve', $event)"
|
||||
@reject="emit('reject', $event)"
|
||||
@review-flagged="emit('reviewFlagged', $event)"
|
||||
@override-flagged="emit('overrideFlagged', $event)"
|
||||
@blacklist-link="emit('blacklist-link', $event)"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 p-4 sm:p-6">
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div class="space-y-2">
|
||||
<!-- Keyboard shortcut legend — fixed above scrollable list -->
|
||||
<div v-if="filteredNominations.length > 0" class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-2xl border border-violet-100 bg-violet-50/60 px-3 py-2">
|
||||
<span class="text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">Tastaturkürzel</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-flex gap-0.5">
|
||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">↑</kbd>
|
||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">↓</kbd>
|
||||
</span>
|
||||
<span class="text-[11px] text-slate-500">/ </span>
|
||||
<span class="inline-flex gap-0.5">
|
||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">J</kbd>
|
||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">K</kbd>
|
||||
</span>
|
||||
<span class="text-[11px] text-slate-500">Navigieren</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-emerald-200 bg-emerald-50 px-1.5 text-[10px] font-bold text-emerald-700 shadow-sm">A</kbd>
|
||||
<span class="text-[11px] text-slate-500">Annehmen</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-rose-200 bg-rose-50 px-1.5 text-[10px] font-bold text-rose-700 shadow-sm">R</kbd>
|
||||
<span class="text-[11px] text-slate-500">Ablehnen</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AdminReviewsQueueList
|
||||
:nominations="filteredNominations"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:selected-nomination-id="selectedNominationId"
|
||||
@select="selectedNominationId = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
:blacklist-saving="blacklistSaving"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
@platform-change="handlePlatformSelection"
|
||||
@approve="approveNomination"
|
||||
@reject="rejectNomination"
|
||||
@blacklist-link="addStreamUrlToBlacklist"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
|
||||
import { Clock3, KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||
@@ -225,6 +225,39 @@ function toneClasses(tone: AdminSettingsTone) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Clock3 class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Session Timeout</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Automatischer Logout bei Inaktivitaet</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Team- und Admin-Sessions werden nach der eingestellten Zeit ohne Aktivitaet automatisch beendet. Minimum sind 3 Stunden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-[minmax(0,220px)_1fr] md:items-start">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Inaktiv nach Stunden</span>
|
||||
<input
|
||||
v-model.number="form.sessionIdleTimeoutHours"
|
||||
:disabled="saving || !canManage"
|
||||
type="number"
|
||||
min="3"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
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 class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
||||
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { Save } from '@lucide/vue'
|
||||
|
||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
form: {
|
||||
clipSubmissionsEnabled: boolean
|
||||
clipReviewEnabled: boolean
|
||||
clipAdminMenuVisible: boolean
|
||||
clipSubmissionDisabledMessage: string
|
||||
}
|
||||
saving: boolean
|
||||
dirty: boolean
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:clipSubmissionsEnabled': [value: boolean]
|
||||
'update:clipReviewEnabled': [value: boolean]
|
||||
'update:clipAdminMenuVisible': [value: boolean]
|
||||
'update:clipSubmissionDisabledMessage': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<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.24em] text-violet-500">Optionale Workflows</p>
|
||||
<h2 class="mt-2 text-lg font-bold text-slate-900">Clip-Einreichungen direkt steuern</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Clip-Einreichung und Clip-Review sind optionale Betriebsfunktionen. Wenn deaktiviert, bleibt der Award-Workflow ohne Clip-Pflicht nutzbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button class="gap-2" :disabled="saving || !dirty || !canManage" @click="emit('save')">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Clip-Workflow speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!canManage" class="border-b border-amber-100 bg-amber-50 px-5 py-3 text-sm font-semibold text-amber-800">
|
||||
Du kannst den Clip-Workflow ansehen, aber nicht bearbeiten.
|
||||
</section>
|
||||
|
||||
<section class="space-y-5 p-5">
|
||||
<section class="grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-bold text-slate-900">Public-Einreichung</h4>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Besucher können während der passenden Phase Clips einreichen.
|
||||
</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="form.clipSubmissionsEnabled"
|
||||
label="Clip-Einreichung aktivieren"
|
||||
:disabled="!canManage"
|
||||
active-label="Aktiv"
|
||||
inactive-label="Inaktiv"
|
||||
@update:model-value="emit('update:clipSubmissionsEnabled', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-bold text-slate-900">Admin-Review</h4>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Clip-Bestand und Alt-Einreichungen bleiben als optionale Inbox sichtbar.
|
||||
</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="form.clipReviewEnabled"
|
||||
label="Admin-Review anzeigen"
|
||||
:disabled="!canManage"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Aus"
|
||||
@update:model-value="emit('update:clipReviewEnabled', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-bold text-slate-900">Menüpunkt „Clips" im Admin</h4>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Blendet den Clips-Menüpunkt im Admin-Panel ein oder aus, unabhängig von Einreichung und Review.
|
||||
</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="form.clipAdminMenuVisible"
|
||||
label="Clips-Menüpunkt anzeigen"
|
||||
:disabled="!canManage"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Aus"
|
||||
@update:model-value="emit('update:clipAdminMenuVisible', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-bold text-slate-900">Hinweis bei deaktivierter Einreichung</span>
|
||||
<textarea
|
||||
:value="form.clipSubmissionDisabledMessage"
|
||||
rows="3"
|
||||
maxlength="240"
|
||||
:disabled="!canManage"
|
||||
class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm leading-6 text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400"
|
||||
@input="emit('update:clipSubmissionDisabledMessage', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-slate-500">
|
||||
{{ form.clipSubmissionDisabledMessage.length }}/240 Zeichen
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<section class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-sky-800">
|
||||
Nominierung, Voting und Gewinnerpflege laufen unabhängig davon weiter. Bestehende Clips werden nicht gelöscht.
|
||||
</section>
|
||||
</section>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,16 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { BookOpenText, CheckCircle2, History, Info, Rocket, ShieldCheck, Sparkles, Tag } from '@lucide/vue'
|
||||
import { BookOpenText, CheckCircle2, ChevronDown, History, Info, Rocket, ShieldCheck, Sparkles, Tag } from '@lucide/vue'
|
||||
|
||||
import { buildDateLabel, buildVersion, releaseNotes, semanticVersion, versionRules, type ReleaseChangeType } from '../../lib/releaseNotes'
|
||||
import { buildDateLabel, buildVersion, releaseNotes, releaseVersions, semanticVersion, versionRules, type ReleaseChangeType } from '../../lib/releaseNotes'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
const changelogOpen = ref(false)
|
||||
const expandedVersions = ref<string[]>([releaseVersions[0]?.version ?? semanticVersion])
|
||||
|
||||
const featureCount = computed(() => releaseNotes.filter((note) => note.type === 'feature').length)
|
||||
const fixCount = computed(() => releaseNotes.filter((note) => note.type === 'fix').length)
|
||||
const versionCount = computed(() => releaseVersions.length)
|
||||
|
||||
function versionFeatureCount(version: typeof releaseVersions[number]) {
|
||||
return version.notes.filter((note) => note.type === 'feature').length
|
||||
}
|
||||
|
||||
function versionFixCount(version: typeof releaseVersions[number]) {
|
||||
return version.notes.filter((note) => note.type === 'fix').length
|
||||
}
|
||||
|
||||
function isVersionExpanded(version: string) {
|
||||
return expandedVersions.value.includes(version)
|
||||
}
|
||||
|
||||
function toggleVersion(version: string) {
|
||||
if (isVersionExpanded(version)) {
|
||||
expandedVersions.value = expandedVersions.value.filter((entry) => entry !== version)
|
||||
return
|
||||
}
|
||||
|
||||
expandedVersions.value = [version]
|
||||
}
|
||||
|
||||
function changeTypeLabel(type: ReleaseChangeType) {
|
||||
if (type === 'major') return 'Major'
|
||||
@@ -83,6 +106,11 @@ function changeTypeClasses(type: ReleaseChangeType) {
|
||||
<strong class="mt-1 block text-lg text-amber-700">{{ fixCount }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-600">Stabilitaet, UI-Feedback und Backend-Hygiene.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50/70 px-4 py-3 md:col-span-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Versionen im Verlauf</p>
|
||||
<strong class="mt-1 block text-lg text-slate-800">{{ versionCount }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-600">Neueste Builds lassen sich im Changelog einzeln auf- und zuklappen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -115,32 +143,80 @@ function changeTypeClasses(type: ReleaseChangeType) {
|
||||
<section>
|
||||
<div class="flex items-center gap-2">
|
||||
<History class="h-4 w-4 text-violet-600" />
|
||||
<h3 class="text-sm font-bold text-slate-900">Was neu ist</h3>
|
||||
<h3 class="text-sm font-bold text-slate-900">Versionen & Aenderungen</h3>
|
||||
</div>
|
||||
<div class="mt-3 space-y-3">
|
||||
<article
|
||||
v-for="note in releaseNotes"
|
||||
:key="note.title"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="changeTypeClasses(note.type).panel"
|
||||
<section
|
||||
v-for="version in releaseVersions"
|
||||
:key="version.version"
|
||||
class="overflow-hidden rounded-[24px] border border-violet-100 bg-white/95 shadow-[0_16px_36px_rgba(124,92,255,0.08)]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="changeTypeClasses(note.type).icon">
|
||||
<Sparkles v-if="note.type === 'feature'" class="h-4 w-4" />
|
||||
<ShieldCheck v-else-if="note.type === 'major'" class="h-4 w-4" />
|
||||
<CheckCircle2 v-else class="h-4 w-4" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between gap-4 bg-gradient-to-r from-white via-violet-50/50 to-white px-5 py-4 text-left transition hover:bg-violet-50/70"
|
||||
@click="toggleVersion(version.version)"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-900">{{ note.title }}</h4>
|
||||
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(note.type).badge">
|
||||
{{ changeTypeLabel(note.type) }}
|
||||
<span class="rounded-full bg-slate-900 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white">
|
||||
{{ version.version }}
|
||||
</span>
|
||||
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(version.releaseType).badge">
|
||||
{{ changeTypeLabel(version.releaseType) }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold text-slate-500">
|
||||
{{ version.buildVersion ?? version.version }} · {{ version.buildDateLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-600">{{ note.description }}</p>
|
||||
<h4 class="mt-3 text-base font-bold text-slate-900">{{ version.summary }}</h4>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-violet-700">
|
||||
{{ versionFeatureCount(version) }} Features
|
||||
</span>
|
||||
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-emerald-700">
|
||||
{{ versionFixCount(version) }} Fixes
|
||||
</span>
|
||||
<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-slate-600">
|
||||
{{ version.notes.length }} Eintraege
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl border border-violet-100 bg-white text-violet-700 transition"
|
||||
:class="{ 'rotate-180': isVersionExpanded(version.version) }"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="isVersionExpanded(version.version)" class="border-t border-violet-100 bg-white px-5 py-4">
|
||||
<div class="space-y-3">
|
||||
<article
|
||||
v-for="note in version.notes"
|
||||
:key="`${version.version}-${note.title}`"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="changeTypeClasses(note.type).panel"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="changeTypeClasses(note.type).icon">
|
||||
<Sparkles v-if="note.type === 'feature'" class="h-4 w-4" />
|
||||
<ShieldCheck v-else-if="note.type === 'major'" class="h-4 w-4" />
|
||||
<CheckCircle2 v-else class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-900">{{ note.title }}</h4>
|
||||
<span class="rounded-full px-2.5 py-1 text-[11px] font-semibold" :class="changeTypeClasses(note.type).badge">
|
||||
{{ changeTypeLabel(note.type) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-600">{{ note.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue'
|
||||
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nomination: AdminNominationReviewItem | null
|
||||
const props = defineProps<{
|
||||
nomination: AdminNominationReviewGroup | null
|
||||
reviewSaving: number | null
|
||||
reviewForm: {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
categoryId: number | null
|
||||
reviewNote: string
|
||||
} | null
|
||||
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||
categoryOptions: Array<{ id: number; label: string }>
|
||||
selectedCandidateCollision: AdminCandidateItem | null
|
||||
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||
signalSummary?: {
|
||||
@@ -22,152 +24,291 @@ defineProps<{
|
||||
uniqueSubmitters: number
|
||||
platforms: string[]
|
||||
} | null
|
||||
trackingReviewNotes?: string
|
||||
selectedNominationHasBlockingFlag?: boolean
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
blacklistSaving: boolean
|
||||
selectedPlatformValue: (platform: string) => string
|
||||
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'platform-change': [value: string]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
reviewFlagged: [nominationId: number]
|
||||
overrideFlagged: [nominationId: number]
|
||||
'blacklist-link': [streamUrl: string]
|
||||
}>()
|
||||
|
||||
function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
return nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')?.windowLabel?.trim() ?? ''
|
||||
}
|
||||
</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 || 'Name im Review festlegen' }}</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="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-4 shadow-[0_14px_36px_rgba(168,145,214,0.08)] sm:p-5">
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-[linear-gradient(135deg,#fcfaff_0%,#ffffff_100%)] p-4 sm:p-5">
|
||||
<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 bg-violet-100 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-violet-800">
|
||||
{{ nomination.categoryGroupName }}
|
||||
</span>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500">
|
||||
Gruppe #{{ nomination.id }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-3 break-words text-xl font-bold text-slate-900 sm:text-2xl">{{ nomination.displayName || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende · {{ nomination.nominationTally }} Nominierungen · zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</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>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
|
||||
:disabled="blacklistSaving"
|
||||
@click="emit('blacklist-link', streamUrl)"
|
||||
>
|
||||
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Ban v-else class="h-3.5 w-3.5" />
|
||||
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-2xl border px-4 py-3 text-sm"
|
||||
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
|
||||
>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em]">Tracker</p>
|
||||
<p class="mt-1 font-semibold">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</p>
|
||||
</div>
|
||||
</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-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">Tier-Vorschlag</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ nomination.suggestedCategoryName || 'manuell wählen' }}</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">Avg Viewer <span v-if="avgViewerWindowLabel(nomination)">· {{ avgViewerWindowLabel(nomination) }}</span></p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ nomination.avgViewers ?? 'offen' }}</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 text-sm text-violet-900">{{ signalSummary?.platforms.join(', ') || nomination.resolvedPlatform || 'keine' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<NativeSelect
|
||||
:model-value="selectedPlatformValue(reviewForm.platform)"
|
||||
:options="[
|
||||
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
|
||||
{ label: 'Eigene Plattform', value: 'custom' },
|
||||
]"
|
||||
@update:model-value="emit('platform-change', String($event))"
|
||||
/>
|
||||
</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.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
|
||||
</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="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link2 class="h-4 w-4 text-sky-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Quelle & Signale</p>
|
||||
</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 v-if="streamUrl" class="mt-3 rounded-2xl border border-sky-100 bg-white p-3">
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="block truncate text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||
>
|
||||
{{ streamUrl }}
|
||||
</a>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-full border-rose-200 bg-rose-50 px-3 text-rose-700 hover:bg-rose-100 hover:text-rose-800"
|
||||
:disabled="blacklistSaving"
|
||||
@click="emit('blacklist-link', streamUrl)"
|
||||
>
|
||||
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Ban v-else class="h-3.5 w-3.5" />
|
||||
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="signalSummary" class="mt-3 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">Nominierungen</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 Nominierende</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">Status</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/75 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">Nominierungen dieser Gruppe</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="related in selectedRelatedPendingNominations"
|
||||
:key="related.id"
|
||||
class="rounded-2xl border border-amber-100 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
>
|
||||
<p class="font-semibold">#{{ related.id }} · {{ related.submittedByTwitchId }}</p>
|
||||
<p class="mt-1 break-words text-slate-500">{{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-[24px] border p-4"
|
||||
:class="nomination.trackingReviewStatus === 'overridden' ? 'border-emerald-100 bg-emerald-50/70' : nomination.requiresManualReview ? 'border-amber-100 bg-amber-50/70' : 'border-violet-100 bg-white'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
|
||||
|
||||
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="flag in nomination.trackingFlags"
|
||||
:key="flag.key"
|
||||
class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
{{ flag.label }}<span v-if="flag.blocksApproval"> · blockiert</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="nomination.trackingMetrics.length" class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div v-for="metric in nomination.trackingMetrics" :key="metric.key" class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">{{ metric.label }}</p>
|
||||
<span
|
||||
class="rounded-full border px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="metric.sourceSupport === 'auto'
|
||||
? (metric.present ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700')
|
||||
: 'border-slate-200 bg-slate-50 text-slate-700'"
|
||||
>
|
||||
{{ metric.sourceSupport === 'auto' ? (metric.present ? 'vorhanden' : 'fehlt') : (metric.sourceSupport === 'manual' ? 'manuell' : 'kontext') }}
|
||||
</span>
|
||||
</div>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ metric.value }}</strong>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="trackingReviewNotes" class="mt-4 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Manual Review Notes</p>
|
||||
<pre class="mt-2 whitespace-pre-wrap text-sm leading-6 text-sky-900">{{ trackingReviewNotes }}</pre>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-violet-50/45 p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserRoundCheck class="h-4 w-4 text-violet-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Kandidaten-Übernahme</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 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">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">Tier</span>
|
||||
<NativeSelect
|
||||
:model-value="reviewForm.categoryId ?? ''"
|
||||
:options="categoryOptions.map((option) => ({ label: option.label, value: option.id }))"
|
||||
@update:model-value="reviewForm.categoryId = Number($event) || null"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<NativeSelect
|
||||
:model-value="selectedPlatformValue(reviewForm.platform)"
|
||||
:options="[
|
||||
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
|
||||
{ label: 'Eigene Plattform', value: 'custom' },
|
||||
]"
|
||||
@update:model-value="emit('platform-change', String($event))"
|
||||
/>
|
||||
</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="!nomination.suggestedCategoryId"
|
||||
class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
||||
>
|
||||
Kein automatischer Tier-Vorschlag vorhanden. Bitte manuell anhand Viewer-Range und Kanal einordnen.
|
||||
</p>
|
||||
|
||||
<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 diesem Tier bereits vorhanden.
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<span v-if="selectedNominationHasBlockingFlag && nomination.trackingReviewStatus !== 'overridden'">
|
||||
Mindestens ein Tracking-Flag blockiert die Freigabe. Bitte zuerst einen Override setzen.
|
||||
</span>
|
||||
<span v-else>
|
||||
Anzeigename, Handle, Plattform und Tier sind Pflicht.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[24px] border border-violet-100 bg-white p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<WandSparkles class="h-4 w-4 text-violet-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Moderationsnotiz & Entscheidung</p>
|
||||
</div>
|
||||
|
||||
<label class="mt-4 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="5"
|
||||
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 class="mt-4 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" class="justify-center" @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" class="justify-center" @click="emit('approve', nomination.id)">
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
tabs: Array<{ id: number | null; label: string; count: number; note?: string }>
|
||||
modelValue: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[24px] border border-violet-100 bg-[linear-gradient(180deg,#fcfaff_0%,#f8f4ff_100%)] p-4">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">Unterkategorien</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Nur die aktuell gewählte Tier-Stufe wird in der Queue gezeigt.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id ?? 'all'"
|
||||
type="button"
|
||||
class="rounded-[20px] border px-4 py-3 text-left transition"
|
||||
:class="modelValue === tab.id ? 'border-violet-300 bg-white text-violet-900 shadow-[0_12px_28px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/75 text-slate-600 hover:border-violet-200 hover:bg-white'"
|
||||
@click="emit('update:modelValue', tab.id)"
|
||||
>
|
||||
<span class="flex items-start justify-between gap-3">
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-semibold">{{ tab.label }}</span>
|
||||
<span v-if="tab.note" class="mt-1 block text-[11px] font-medium text-slate-500">{{ tab.note }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex min-h-8 min-w-8 items-center justify-center rounded-full border px-2 text-xs font-bold"
|
||||
:class="modelValue === tab.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-violet-50 text-violet-700'"
|
||||
>
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import AdminNominationReviewModal from './AdminNominationReviewModal.vue'
|
||||
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
|
||||
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
||||
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
||||
import AdminReviewSubcategoryTabs from './AdminReviewSubcategoryTabs.vue'
|
||||
import { getTrackerStatusMeta, useAdminReviewsManager } from './useAdminReviewsManager'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
categoryIdsFilter?: number[] | null
|
||||
activeMainCategory?: string | null
|
||||
showCategoryFilterChips?: boolean
|
||||
historyCollapsible?: boolean
|
||||
showBlacklistButton?: boolean
|
||||
}>(), {
|
||||
categoryIdsFilter: null,
|
||||
activeMainCategory: null,
|
||||
showCategoryFilterChips: true,
|
||||
historyCollapsible: false,
|
||||
showBlacklistButton: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-blacklist': []
|
||||
}>()
|
||||
|
||||
const {
|
||||
reviewSaving,
|
||||
blacklistSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
subcategoryFilter,
|
||||
selectedNominationId,
|
||||
isReviewModalOpen,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
availableSubcategoryOptions,
|
||||
tierCategoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
trackingReviewNotes,
|
||||
selectedNominationHasBlockingFlag,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
reopenRejectedNomination,
|
||||
updateTrackingReview,
|
||||
addStreamUrlToBlacklist,
|
||||
openNominationById,
|
||||
closeNominationModal,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
} = useAdminReviewsManager({
|
||||
categoryIdsFilter: () => props.categoryIdsFilter,
|
||||
})
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
:category-options="categoryOptions"
|
||||
:active-main-category="props.activeMainCategory"
|
||||
:show-category-filter-chips="showCategoryFilterChips"
|
||||
:show-blacklist-button="showBlacklistButton"
|
||||
@open-blacklist="emit('open-blacklist')"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 p-4 sm:p-6">
|
||||
<AdminReviewSubcategoryTabs
|
||||
v-model="subcategoryFilter"
|
||||
:tabs="availableSubcategoryOptions"
|
||||
/>
|
||||
|
||||
<AdminReviewsQueueList
|
||||
:nominations="filteredNominations"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:selected-nomination-id="selectedNominationId"
|
||||
:tracker-status-meta="getTrackerStatusMeta"
|
||||
@select="selectedNominationId = $event"
|
||||
@open="openNominationById"
|
||||
/>
|
||||
|
||||
<details v-if="historyCollapsible" 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"
|
||||
:review-saving="reviewSaving"
|
||||
@undo-rejected="reopenRejectedNomination"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<AdminReviewsHistorySection
|
||||
v-else
|
||||
:reviewed-nominations="reviewedNominations"
|
||||
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||
:review-saving="reviewSaving"
|
||||
@undo-rejected="reopenRejectedNomination"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminNominationReviewModal
|
||||
:open="isReviewModalOpen"
|
||||
:nomination="selectedNomination"
|
||||
:review-saving="reviewSaving"
|
||||
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:category-options="tierCategoryOptions"
|
||||
:selected-candidate-collision="selectedCandidateCollision"
|
||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||
:signal-summary="selectedNominationSignalSummary"
|
||||
:tracking-review-notes="trackingReviewNotes"
|
||||
:selected-nomination-has-blocking-flag="selectedNominationHasBlockingFlag"
|
||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||
:can-approve-selected="canApproveSelected"
|
||||
:blacklist-saving="blacklistSaving"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
:tracker-status-meta="getTrackerStatusMeta"
|
||||
@close="closeNominationModal"
|
||||
@platform-change="handlePlatformSelection"
|
||||
@approve="approveNomination"
|
||||
@reject="rejectNomination"
|
||||
@review-flagged="updateTrackingReview($event, 'reviewed')"
|
||||
@override-flagged="updateTrackingReview($event, 'overridden')"
|
||||
@blacklist-link="addStreamUrlToBlacklist"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { RotateCcw } from '@lucide/vue'
|
||||
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
reviewedTotal: number
|
||||
reviewSaving?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'undo-rejected': [nominationId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -28,9 +35,21 @@ defineProps<{
|
||||
<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 class="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
v-if="nomination.status === 'rejected'"
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center gap-2 rounded-full border border-violet-200 bg-violet-50 px-3 text-xs font-semibold text-violet-700 transition hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="reviewSaving === nomination.id"
|
||||
@click="emit('undo-rejected', nomination.id)"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
{{ reviewSaving === nomination.id ? 'Öffnet ...' : 'Undo' }}
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@lucide/vue'
|
||||
import { Ban, Search } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
reviewFilter: string
|
||||
categoryFilter: number | null
|
||||
categoryFilter: string | null
|
||||
totalPending: number
|
||||
visibleCount: number
|
||||
reviewStats: Array<{ label: string; value: number }>
|
||||
categoryOptions: Array<{ id: number; label: string; count: number }>
|
||||
categoryOptions: Array<{ id: string; label: string; count: number }>
|
||||
activeMainCategory?: string | null
|
||||
showCategoryFilterChips?: boolean
|
||||
showBlacklistButton?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:reviewFilter': [value: string]
|
||||
'update:categoryFilter': [value: number | null]
|
||||
'update:categoryFilter': [value: string | null]
|
||||
'open-blacklist': []
|
||||
}>()
|
||||
</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 class="space-y-5">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start 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-2xl font-bold text-slate-900">{{ activeMainCategory || 'Offene Nominierungen' }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Offene Nominierungen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<button
|
||||
v-if="showBlacklistButton"
|
||||
type="button"
|
||||
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
|
||||
@click="emit('open-blacklist')"
|
||||
>
|
||||
<Ban class="h-4 w-4" />
|
||||
Blacklist verwalten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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
|
||||
@@ -45,11 +50,9 @@ const emit = defineEmits<{
|
||||
@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">
|
||||
|
||||
<div v-if="showCategoryFilterChips !== false" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
|
||||
@@ -1,42 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
import type { AdminNominationReviewGroup } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nominations: AdminNominationReviewItem[]
|
||||
const props = defineProps<{
|
||||
nominations: AdminNominationReviewGroup[]
|
||||
totalPending: number
|
||||
selectedNominationId: number | null
|
||||
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [nominationId: number]
|
||||
open: [nominationId: number]
|
||||
}>()
|
||||
|
||||
function avgViewerLabel(nomination: AdminNominationReviewGroup) {
|
||||
if (nomination.avgViewers === null) return null
|
||||
const avgViewerMetric = nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')
|
||||
const windowLabel = avgViewerMetric?.windowLabel?.trim()
|
||||
return windowLabel
|
||||
? `${nomination.avgViewers} Avg Viewer · ${windowLabel}`
|
||||
: `${nomination.avgViewers} Avg Viewer`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||
<div class="space-y-3 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||
<div v-if="nominations.length > 0" class="rounded-[22px] border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold text-slate-600">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-500">Queue-Navigation</span>
|
||||
<div class="flex flex-wrap gap-2 text-xs font-semibold text-slate-600">
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1.5">
|
||||
<kbd class="rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">↑</kbd>
|
||||
<kbd class="ml-1 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">↓</kbd>
|
||||
<span class="ml-2">oder</span>
|
||||
<kbd class="ml-2 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">J</kbd>
|
||||
<kbd class="ml-1 rounded border border-violet-200 bg-violet-50 px-1.5 py-0.5 text-[10px] font-bold text-violet-700">K</kbd>
|
||||
<span class="ml-2">Navigieren</span>
|
||||
</span>
|
||||
<span class="rounded-full border border-sky-100 bg-sky-50 px-3 py-1.5 text-sky-700">
|
||||
<kbd class="rounded border border-sky-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">Enter</kbd>
|
||||
<span class="ml-2">Modal öffnen</span>
|
||||
</span>
|
||||
<span class="rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1.5 text-emerald-700">
|
||||
<kbd class="rounded border border-emerald-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">A</kbd>
|
||||
<span class="ml-2">Annehmen</span>
|
||||
</span>
|
||||
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-rose-700">
|
||||
<kbd class="rounded border border-rose-200 bg-white px-1.5 py-0.5 text-[10px] font-bold">R</kbd>
|
||||
<span class="ml-2">Verwerfen</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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'"
|
||||
class="w-full rounded-[24px] border p-4 text-left transition"
|
||||
:class="selectedNominationId === nomination.id ? 'border-violet-300 bg-white shadow-[0_18px_34px_rgba(168,145,214,0.14)] ring-2 ring-violet-100' : '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
|
||||
class="rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||
:class="selectedNominationId === nomination.id ? 'bg-violet-100 text-violet-800' : 'bg-violet-50 text-violet-700'"
|
||||
>
|
||||
{{ nomination.categoryGroupName }}
|
||||
</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 }}
|
||||
{{ nomination.uniqueSubmitterCount }} Nominierende
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]"
|
||||
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
|
||||
>
|
||||
{{ props.trackerStatusMeta(nomination.trackerStatus).label }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
<h3 class="mt-2 line-clamp-2 text-base font-semibold text-slate-900">{{ nomination.displayName || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs font-semibold text-slate-500">
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50/50 px-2.5 py-1">{{ nomination.suggestedCategoryName || 'Tier offen' }}</span>
|
||||
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.nominationTally }} Nominierungen</span>
|
||||
<span class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1">{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende</span>
|
||||
<span v-if="avgViewerLabel(nomination)" class="rounded-full border border-sky-100 bg-sky-50 px-2.5 py-1 text-sky-700">{{ avgViewerLabel(nomination) }}</span>
|
||||
<span
|
||||
v-if="nomination.trackingFlags.some((flag) => flag.blocksApproval)"
|
||||
class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-rose-700"
|
||||
>
|
||||
Blockierende Flags
|
||||
</span>
|
||||
<span
|
||||
v-else-if="nomination.requiresManualReview"
|
||||
class="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
|
||||
>
|
||||
Manuelle Pruefung
|
||||
</span>
|
||||
<span
|
||||
v-if="!nomination.suggestedCategoryName"
|
||||
class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-600"
|
||||
>
|
||||
Tier manuell waehlen
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs font-medium text-slate-400">
|
||||
zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||
<div
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-bold"
|
||||
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-500'"
|
||||
>
|
||||
#{{ nomination.id }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
|
||||
@click.stop="emit('open', nomination.id)"
|
||||
>
|
||||
Prüfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { Clock3 } from '@lucide/vue'
|
||||
|
||||
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||
|
||||
defineProps<{
|
||||
form: AdminOperationalSettingsForm
|
||||
saving: boolean
|
||||
canManage: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Clock3 class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Session Timeout</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Automatischer Logout bei Inaktivitaet</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Team- und Admin-Sessions werden nach der eingestellten Zeit ohne Aktivitaet automatisch beendet. Minimum sind 3 Stunden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-[minmax(0,220px)_1fr] md:items-start">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Inaktiv nach Stunden</span>
|
||||
<input
|
||||
v-model.number="form.sessionIdleTimeoutHours"
|
||||
:disabled="saving || !canManage"
|
||||
type="number"
|
||||
min="3"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
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 class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
||||
Der Logout reagiert auf echte Nutzeraktivitaet wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und laeuft spaetestens nach 30 Tagen absolut ab.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,493 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Check, ChevronDown, ChevronUp, Eye, Plus, Save, Star, Trash2 } from '@lucide/vue'
|
||||
|
||||
import type { ShowactFormField, ShowactFieldType } from '../../types/awards/showactForm'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
const expandedIndex = ref<number | null>(null)
|
||||
const previewOpen = ref(false)
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
|
||||
const fieldTypeOptions: { value: ShowactFieldType; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'email', label: 'E-Mail' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'textarea', label: 'Textbereich' },
|
||||
{ value: 'select', label: 'Auswahl (Select)' },
|
||||
{ value: 'radio', label: 'Einzelauswahl' },
|
||||
{ value: 'multiselect', label: 'Mehrfachauswahl' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
]
|
||||
|
||||
function fieldTypeBadge(type: ShowactFieldType) {
|
||||
return {
|
||||
text: 'Text',
|
||||
email: 'E-Mail',
|
||||
url: 'URL',
|
||||
textarea: 'Textarea',
|
||||
select: 'Select',
|
||||
radio: 'Radio',
|
||||
multiselect: 'Multi',
|
||||
checkbox: 'Checkbox',
|
||||
}[type] ?? type
|
||||
}
|
||||
|
||||
function toggleExpand(index: number) {
|
||||
expandedIndex.value = expandedIndex.value === index ? null : index
|
||||
}
|
||||
|
||||
function addField() {
|
||||
const newField: ShowactFormField = {
|
||||
id: `field_${Date.now()}`,
|
||||
type: 'text',
|
||||
label: 'Neues Feld',
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
required: false,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
allowOther: false,
|
||||
maxLength: 200,
|
||||
}
|
||||
props.form.showactFormSchema.push(newField)
|
||||
expandedIndex.value = props.form.showactFormSchema.length - 1
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
props.form.showactFormSchema.splice(index, 1)
|
||||
if (expandedIndex.value === index) {
|
||||
expandedIndex.value = null
|
||||
} else if (expandedIndex.value !== null && expandedIndex.value > index) {
|
||||
expandedIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
function moveUp(index: number) {
|
||||
if (index === 0) return
|
||||
const arr = props.form.showactFormSchema
|
||||
const temp = arr[index - 1]
|
||||
arr[index - 1] = arr[index]
|
||||
arr[index] = temp
|
||||
if (expandedIndex.value === index) expandedIndex.value = index - 1
|
||||
else if (expandedIndex.value === index - 1) expandedIndex.value = index
|
||||
}
|
||||
|
||||
function moveDown(index: number) {
|
||||
const arr = props.form.showactFormSchema
|
||||
if (index >= arr.length - 1) return
|
||||
const temp = arr[index + 1]
|
||||
arr[index + 1] = arr[index]
|
||||
arr[index] = temp
|
||||
if (expandedIndex.value === index) expandedIndex.value = index + 1
|
||||
else if (expandedIndex.value === index + 1) expandedIndex.value = index
|
||||
}
|
||||
|
||||
function setArtistName(index: number, value: boolean) {
|
||||
if (value) {
|
||||
props.form.showactFormSchema.forEach((f, i) => {
|
||||
f.isArtistName = i === index
|
||||
})
|
||||
} else {
|
||||
props.form.showactFormSchema[index].isArtistName = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRequired(field: ShowactFormField) {
|
||||
field.required = !field.required
|
||||
}
|
||||
|
||||
function updateFieldType(field: ShowactFormField, type: ShowactFieldType) {
|
||||
field.type = type
|
||||
|
||||
if (type !== 'multiselect') {
|
||||
field.allowOther = false
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && field.options.length === 0) {
|
||||
field.options = ['Ja']
|
||||
} else if (type === 'radio' && field.options.length === 0) {
|
||||
field.options = ['Ja', 'Nein']
|
||||
} else if ((type === 'select' || type === 'multiselect') && field.options.length === 0) {
|
||||
field.options = type === 'select' ? ['Option 1'] : ['Option 1', 'Option 2']
|
||||
}
|
||||
|
||||
if (type === 'textarea' && (!field.maxLength || field.maxLength < 500)) {
|
||||
field.maxLength = 1500
|
||||
} else if ((type === 'email' || type === 'url') && (!field.maxLength || field.maxLength < 200)) {
|
||||
field.maxLength = 500
|
||||
} else if (type === 'checkbox' && (!field.maxLength || field.maxLength > 100)) {
|
||||
field.maxLength = 20
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionsText(field: ShowactFormField): string {
|
||||
return field.options.join('\n')
|
||||
}
|
||||
|
||||
function setOptionsText(field: ShowactFormField, text: string) {
|
||||
field.options = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
try {
|
||||
await props.saveSiteSettings('Showact-Formular')
|
||||
saveMessage.value = 'Formular-Schema gespeichert.'
|
||||
} catch (error) {
|
||||
saveError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
|
||||
}
|
||||
}
|
||||
|
||||
function previewOptionLabel(field: ShowactFormField) {
|
||||
if (field.type === 'checkbox') {
|
||||
return field.options[0] || 'Ja'
|
||||
}
|
||||
|
||||
return field.placeholder || 'Antwort'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-slate-900">Formular-Builder</h3>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Felder definieren, die im Bewerbungs-Modal angezeigt werden.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50"
|
||||
@click="previewOpen = true"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
Vorschau
|
||||
</Button>
|
||||
<Button class="gap-2" :disabled="props.saving" @click="save">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ props.saving ? 'Speichert ...' : 'Schema speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="saveMessage" class="text-sm font-semibold text-emerald-700">{{ saveMessage }}</p>
|
||||
<p v-if="saveError" class="text-sm font-semibold text-rose-700">{{ saveError }}</p>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-white">
|
||||
<div
|
||||
v-for="(field, index) in props.form.showactFormSchema"
|
||||
:key="field.id"
|
||||
class="border-b border-violet-100 last:border-b-0"
|
||||
>
|
||||
<!-- Field header row -->
|
||||
<div class="flex items-center gap-2 px-4 py-3">
|
||||
<!-- Up/Down buttons -->
|
||||
<div class="flex shrink-0 flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === 0"
|
||||
class="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:text-violet-700 disabled:opacity-30"
|
||||
@click="moveUp(index)"
|
||||
>
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === props.form.showactFormSchema.length - 1"
|
||||
class="flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:text-violet-700 disabled:opacity-30"
|
||||
@click="moveDown(index)"
|
||||
>
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Type badge -->
|
||||
<span class="shrink-0 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-bold text-violet-700">
|
||||
{{ fieldTypeBadge(field.type) }}
|
||||
</span>
|
||||
|
||||
<!-- Label + indicators -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
@click="toggleExpand(index)"
|
||||
>
|
||||
<span class="truncate text-sm font-semibold text-slate-800">{{ field.label || '(kein Label)' }}</span>
|
||||
<span v-if="field.required" class="shrink-0 text-xs font-bold text-rose-600">*</span>
|
||||
<Star v-if="field.isArtistName" class="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-500" />
|
||||
</button>
|
||||
|
||||
<!-- Delete + expand -->
|
||||
<button
|
||||
type="button"
|
||||
:disabled="props.form.showactFormSchema.length <= 1"
|
||||
class="shrink-0 rounded p-1 text-slate-400 hover:text-rose-600 disabled:opacity-30"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-1 text-slate-400 hover:text-violet-700"
|
||||
@click="toggleExpand(index)"
|
||||
>
|
||||
<ChevronDown v-if="expandedIndex !== index" class="h-4 w-4" />
|
||||
<ChevronUp v-else class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded editor -->
|
||||
<div v-if="expandedIndex === index" class="space-y-3 border-t border-violet-100 bg-violet-50/40 px-4 py-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<!-- Field type -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Feldtyp</span>
|
||||
<select
|
||||
:value="field.type"
|
||||
class="min-h-12 w-full rounded-2xl border-2 border-violet-100 bg-white px-4 py-3 text-sm font-bold text-slate-800 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@change="updateFieldType(field, (($event.target as HTMLSelectElement).value || 'text') as ShowactFieldType)"
|
||||
>
|
||||
<option v-for="option in fieldTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Field ID -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Feld-ID (eindeutig)</span>
|
||||
<input
|
||||
v-model="field.id"
|
||||
maxlength="80"
|
||||
placeholder="z.B. artist_name"
|
||||
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<!-- Label -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Label</span>
|
||||
<input
|
||||
v-model="field.label"
|
||||
maxlength="500"
|
||||
placeholder="Anzeige-Name"
|
||||
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Placeholder -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Platzhaltertext</span>
|
||||
<input
|
||||
v-model="field.placeholder"
|
||||
maxlength="500"
|
||||
placeholder="Hinweistext im leeren Feld"
|
||||
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Hilfetext / Beschreibung</span>
|
||||
<textarea
|
||||
v-model="field.helpText"
|
||||
rows="2"
|
||||
maxlength="1000"
|
||||
placeholder="Optionaler Text unter dem Label"
|
||||
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- MaxLength -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Max. Zeichenanzahl</span>
|
||||
<input
|
||||
v-model.number="field.maxLength"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
class="w-36 rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Options for choice fields -->
|
||||
<label v-if="field.type === 'select' || field.type === 'radio' || field.type === 'multiselect' || field.type === 'checkbox'" class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-bold text-slate-700">Optionen (eine pro Zeile)</span>
|
||||
<textarea
|
||||
:value="getOptionsText(field)"
|
||||
rows="4"
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-700 outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@input="setOptionsText(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Toggles row -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Required toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-sm font-semibold transition"
|
||||
:class="field.required
|
||||
? 'border-violet-300 bg-violet-100/90 text-violet-900 shadow-[0_10px_24px_rgba(139,108,219,0.14)]'
|
||||
: 'border-violet-200 bg-white text-slate-700 hover:border-violet-300 hover:bg-violet-50'"
|
||||
@click="toggleRequired(field)"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-md border transition"
|
||||
:class="field.required
|
||||
? 'border-violet-500 bg-violet-600 text-white'
|
||||
: 'border-violet-200 bg-violet-50 text-transparent'"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span>Pflichtfeld</span>
|
||||
</button>
|
||||
|
||||
<!-- isArtistName toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-sm font-semibold transition"
|
||||
:class="field.isArtistName
|
||||
? 'border-amber-300 bg-amber-50 text-amber-900 shadow-[0_10px_24px_rgba(251,191,36,0.16)]'
|
||||
: 'border-violet-200 bg-white text-slate-700 hover:border-amber-300 hover:bg-amber-50/70'"
|
||||
@click="setArtistName(index, !field.isArtistName)"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-md border transition"
|
||||
:class="field.isArtistName
|
||||
? 'border-amber-400 bg-amber-400 text-white'
|
||||
: 'border-amber-200 bg-amber-50 text-transparent'"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span>Künstlername-Feld</span>
|
||||
<Star class="h-3.5 w-3.5 fill-amber-400 text-amber-500" />
|
||||
</button>
|
||||
|
||||
<!-- Other option toggle -->
|
||||
<button
|
||||
v-if="field.type === 'multiselect'"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-sm font-semibold transition"
|
||||
:class="field.allowOther
|
||||
? 'border-violet-300 bg-violet-100/90 text-violet-900 shadow-[0_10px_24px_rgba(139,108,219,0.14)]'
|
||||
: 'border-violet-200 bg-white text-slate-700 hover:border-violet-300 hover:bg-violet-50'"
|
||||
@click="field.allowOther = !field.allowOther"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-md border transition"
|
||||
:class="field.allowOther
|
||||
? 'border-violet-500 bg-violet-600 text-white'
|
||||
: 'border-violet-200 bg-violet-50 text-transparent'"
|
||||
>
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span>„Andere" erlauben</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.form.showactFormSchema.length === 0" class="px-4 py-6 text-center text-sm text-slate-500">
|
||||
Noch keine Felder definiert. Klicke unten auf „Feld hinzufügen".
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addField">
|
||||
<Plus class="h-4 w-4" />
|
||||
Feld hinzufügen
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
:open="previewOpen"
|
||||
size="lg"
|
||||
title="Formular-Vorschau"
|
||||
subtitle="So werden die aktuell konfigurierten Felder im Bewerbungsformular aufgebaut."
|
||||
@close="previewOpen = false"
|
||||
>
|
||||
<div v-if="props.form.showactFormSchema.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/60 p-6 text-center text-sm font-semibold text-slate-500">
|
||||
Noch keine Felder fuer die Vorschau vorhanden.
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="field in props.form.showactFormSchema"
|
||||
:key="field.id"
|
||||
class="rounded-2xl border border-violet-100 bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<label class="text-sm font-black text-violet-800">
|
||||
{{ field.label || '(kein Label)' }}<span v-if="field.required" class="text-rose-600"> *</span>
|
||||
</label>
|
||||
<span class="rounded-full bg-violet-100 px-2.5 py-1 text-xs font-black text-violet-700">{{ fieldTypeBadge(field.type) }}</span>
|
||||
</div>
|
||||
<p v-if="field.helpText" class="mt-1 text-xs font-semibold leading-5 text-slate-500">{{ field.helpText }}</p>
|
||||
|
||||
<input
|
||||
v-if="field.type === 'text' || field.type === 'email' || field.type === 'url'"
|
||||
disabled
|
||||
:placeholder="field.placeholder || previewOptionLabel(field)"
|
||||
class="mt-3 w-full rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-500"
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
disabled
|
||||
rows="3"
|
||||
:placeholder="field.placeholder || 'Lange Antwort'"
|
||||
class="mt-3 w-full resize-none rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-500"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
disabled
|
||||
class="mt-3 w-full rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-500"
|
||||
>
|
||||
<option>{{ field.placeholder || 'Bitte waehlen' }}</option>
|
||||
<option v-for="option in field.options" :key="option">{{ option }}</option>
|
||||
</select>
|
||||
<div v-else-if="field.type === 'radio' || field.type === 'multiselect'" class="mt-3 space-y-2">
|
||||
<label
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
class="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-600"
|
||||
>
|
||||
<span
|
||||
class="h-4 w-4 border border-violet-300 bg-white"
|
||||
:class="field.type === 'radio' ? 'rounded-full' : 'rounded'"
|
||||
/>
|
||||
{{ option }}
|
||||
</label>
|
||||
<label v-if="field.type === 'multiselect' && field.allowOther" class="flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-600">
|
||||
<span class="h-4 w-4 rounded border border-violet-300 bg-white" />
|
||||
Andere:
|
||||
<span class="min-w-28 flex-1 border-b border-violet-200 text-slate-400">Eigene Antwort</span>
|
||||
</label>
|
||||
</div>
|
||||
<label
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
class="mt-3 flex items-center gap-3 rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-slate-600"
|
||||
>
|
||||
<span class="h-4 w-4 rounded border border-violet-300 bg-white" />
|
||||
{{ field.options[0] || 'Ja' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,6 +51,7 @@ function iconForTone(tone: string) {
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="!toast.persistent"
|
||||
type="button"
|
||||
class="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
aria-label="Toast schließen"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2, LockKeyhole, Save, Settings } from '@lucide/vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { AdminOperationalSettingsForm } from './adminSettingsTypes'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import PasswordField from '../ui/PasswordField.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminOperationalSettingsForm
|
||||
saving: boolean
|
||||
canManage: boolean
|
||||
twitchClientSecret: string
|
||||
twitchSecretHint: string
|
||||
twitchClientSecretSet: boolean
|
||||
twitchAuthConfigured: boolean
|
||||
twitchAuthManagedByDatabase: boolean
|
||||
twitchAuthComplete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:twitchClientSecret': [value: string]
|
||||
}>()
|
||||
|
||||
const twitchModalOpen = ref(false)
|
||||
|
||||
function defaultRedirectUri() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'https://deine-domain.de/api/auth/twitch/callback'
|
||||
}
|
||||
|
||||
return `${window.location.origin.replace(/:\d+$/, ':5084')}/api/auth/twitch/callback`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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-center 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">
|
||||
<Settings class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Twitch OAuth</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Offiziellen Twitch Login konfigurieren</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Einmal mit Client-ID, Client Secret und Redirect URI verbinden. Danach verwenden Login und Account-Verknüpfung den offiziellen Twitch-Flow.
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full px-3 py-1" :class="twitchAuthConfigured ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ twitchAuthConfigured ? 'OAuth bereit' : 'OAuth fehlt' }}
|
||||
</span>
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">
|
||||
{{ twitchAuthManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1" :class="twitchClientSecretSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ twitchClientSecretSet ? 'Secret vorhanden' : 'Secret fehlt' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="gap-2 rounded-2xl px-5"
|
||||
:disabled="saving || !canManage"
|
||||
@click="twitchModalOpen = true"
|
||||
>
|
||||
<LockKeyhole v-if="!canManage" class="h-4 w-4" />
|
||||
<Settings v-else class="h-4 w-4" />
|
||||
{{ !canManage ? 'Nur Owner' : 'Twitch konfigurieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
:open="twitchModalOpen"
|
||||
title="Twitch OAuth"
|
||||
subtitle="Client-Daten aus der Twitch Developer Console. Das Secret wird gespeichert, aber nie wieder angezeigt."
|
||||
@close="twitchModalOpen = false"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
|
||||
In Twitch muss dieselbe Redirect URI eingetragen sein, die hier gespeichert ist. Ohne eigene Redirect URI nutzt das Backend automatisch den Callback deiner API.
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Client-ID</span>
|
||||
<input
|
||||
v-model="form.twitchClientId"
|
||||
:disabled="saving || !canManage"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Twitch Client-ID"
|
||||
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">Client Secret</span>
|
||||
<PasswordField
|
||||
:model-value="twitchClientSecret"
|
||||
:disabled="saving || !canManage"
|
||||
autocomplete="new-password"
|
||||
placeholder="Leer lassen = behalten"
|
||||
root-class="mt-2"
|
||||
input-class="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"
|
||||
@update:model-value="emit('update:twitchClientSecret', $event)"
|
||||
/>
|
||||
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ twitchSecretHint }}</span>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Redirect URI</span>
|
||||
<input
|
||||
v-model="form.twitchRedirectUri"
|
||||
:disabled="saving || !canManage"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
:placeholder="defaultRedirectUri()"
|
||||
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">Muss exakt in deiner Twitch-App unter OAuth Redirect URLs eingetragen sein.</span>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Scopes</span>
|
||||
<input
|
||||
v-model="form.twitchScope"
|
||||
:disabled="saving || !canManage"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Optional, z.B. user:read:email"
|
||||
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">Für reines Login/Profil bleibt das Feld normalerweise leer.</span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="form.twitchClientId && !twitchAuthComplete"
|
||||
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
Zum Aktivieren fehlt noch ein Client Secret.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="button" variant="ghost" :disabled="saving" @click="twitchModalOpen = false">Schließen</Button>
|
||||
<Button type="button" class="gap-2" :disabled="saving || !canManage" @click="emit('save')">
|
||||
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Twitch speichern' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -24,6 +24,8 @@ const emit = defineEmits<{
|
||||
updateRule: [ruleKey: string, patch: Partial<AdminWorkflowRule>]
|
||||
save: []
|
||||
}>()
|
||||
|
||||
const advisoryOnlyRuleKey = 'recommended_nominators_per_subcategory'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -96,7 +98,15 @@ const emit = defineEmits<{
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Modus</span>
|
||||
<div
|
||||
v-if="rule.key === advisoryOnlyRuleKey"
|
||||
class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold leading-5 text-slate-500"
|
||||
>
|
||||
Hinweis-Regel<br>
|
||||
<span class="font-normal">Diese Regel bleibt immer unverbindlich und blockiert nichts.</span>
|
||||
</div>
|
||||
<NativeSelect
|
||||
v-else
|
||||
:model-value="rule.mode"
|
||||
:disabled="!canManage"
|
||||
:options="[
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ShowactFormField } from '../../types/awards/showactForm'
|
||||
|
||||
export type SocialLinkForm = {
|
||||
label: string
|
||||
platform: string
|
||||
@@ -16,6 +18,8 @@ export type AdminContentForm = {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
privacyEmail: string
|
||||
privacyPolicyContent: string
|
||||
imprintUrl: string
|
||||
@@ -28,4 +32,5 @@ export type AdminContentForm = {
|
||||
showactsContent: string
|
||||
socialLinks: SocialLinkForm[]
|
||||
faq: FaqFormItem[]
|
||||
showactFormSchema: ShowactFormField[]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,29 @@ export interface AdminSeasonReadinessItem {
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonGatewayEntryLink {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonGatewayCheckItem {
|
||||
label: string
|
||||
detail: string
|
||||
status: 'ok' | 'warn' | 'block'
|
||||
to: string
|
||||
entries?: AdminSeasonGatewayEntryLink[]
|
||||
}
|
||||
|
||||
export interface AdminSeasonPhaseGateway {
|
||||
action: 'activate' | 'complete'
|
||||
phase: string
|
||||
title: string
|
||||
subtitle: string
|
||||
blockers: AdminSeasonGatewayCheckItem[]
|
||||
warnings: AdminSeasonGatewayCheckItem[]
|
||||
confirmsLabel: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonCopyOption {
|
||||
label: string
|
||||
value: number | null
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface AdminOperationalSettingsForm {
|
||||
twitchClientId: string
|
||||
twitchRedirectUri: string
|
||||
twitchScope: string
|
||||
sessionIdleTimeoutHours: number
|
||||
maintenanceModeEnabled: boolean
|
||||
maintenanceTitle: string
|
||||
maintenanceMessage: string
|
||||
|
||||
@@ -86,21 +86,27 @@ export function useAdminAnalyticsManager() {
|
||||
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',
|
||||
to: emptyCategories.value.length > 0
|
||||
? `/admin/categories?group=${encodeURIComponent(emptyCategories.value[0]!.groupName)}`
|
||||
: '/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',
|
||||
to: categoriesWithoutWinner.value.length > 0
|
||||
? `/admin/winners?status=open&categoryId=${categoriesWithoutWinner.value[0]!.id}`
|
||||
: '/admin/winners?status=set',
|
||||
},
|
||||
{
|
||||
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',
|
||||
to: categoriesWithReviews.value.length > 0
|
||||
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews.value[0]!.id}&status=heavy`
|
||||
: '/admin/clips?status=pending',
|
||||
},
|
||||
])
|
||||
|
||||
@@ -127,6 +133,9 @@ export function useAdminAnalyticsManager() {
|
||||
value: String(busiestReviewCategory?.reviews ?? 0),
|
||||
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine offenen Review-Hotspots.',
|
||||
icon: Clock3,
|
||||
to: busiestReviewCategory
|
||||
? `/admin/nominations?review=1&categoryId=${busiestReviewCategory.id}&status=heavy`
|
||||
: '/admin/nominations?review=1',
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -137,7 +146,9 @@ export function useAdminAnalyticsManager() {
|
||||
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',
|
||||
to: emptyCategories.value.length > 0
|
||||
? `/admin/categories?group=${encodeURIComponent(emptyCategories.value[0]!.groupName)}`
|
||||
: '/admin/categories',
|
||||
tone: emptyCategories.value.length > 0 ? 'border-rose-100 bg-rose-50 text-rose-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
@@ -145,7 +156,9 @@ export function useAdminAnalyticsManager() {
|
||||
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',
|
||||
to: categoriesWithReviews.value.length > 0
|
||||
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews.value[0]!.id}&status=heavy`
|
||||
: '/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',
|
||||
},
|
||||
{
|
||||
@@ -153,7 +166,9 @@ export function useAdminAnalyticsManager() {
|
||||
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',
|
||||
to: categoriesWithoutWinner.value.length > 0
|
||||
? `/admin/winners?status=open&categoryId=${categoriesWithoutWinner.value[0]!.id}`
|
||||
: '/admin/winners?status=set',
|
||||
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -17,6 +18,11 @@ interface CandidateForm {
|
||||
clipEmbedStatus: string
|
||||
}
|
||||
|
||||
type CandidateRuleNotice = {
|
||||
mode: 'warn' | 'block'
|
||||
message: string
|
||||
}
|
||||
|
||||
const pageSize = 10
|
||||
const maxFinalistsRuleKey = 'max_finalists_per_category'
|
||||
const maxCandidateAppearancesRuleKey = 'max_candidate_appearances'
|
||||
@@ -34,6 +40,7 @@ const clipEmbedStatusOptions = [
|
||||
]
|
||||
const readinessFilterOptions = [
|
||||
{ label: 'Alle', value: 'all' },
|
||||
{ label: 'Zusage offen', value: 'needs_decision' },
|
||||
{ label: 'Offen', value: 'open' },
|
||||
{ label: 'Angefragt', value: 'contacted' },
|
||||
{ label: 'Angenommen', value: 'accepted' },
|
||||
@@ -44,6 +51,7 @@ const readinessFilterOptions = [
|
||||
|
||||
export function useAdminCandidateManager() {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const adminMessage = ref('')
|
||||
@@ -97,6 +105,10 @@ export function useAdminCandidateManager() {
|
||||
seasonDetail.value.candidates,
|
||||
workflowRules.value,
|
||||
))
|
||||
const candidateIdentitySummaries = computed(() => buildCandidateIdentitySummaries(
|
||||
seasonDetail.value.candidates,
|
||||
seasonDetail.value.results,
|
||||
))
|
||||
const acceptedCandidateCount = computed(() =>
|
||||
seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus === 'accepted').length,
|
||||
)
|
||||
@@ -120,6 +132,10 @@ export function useAdminCandidateManager() {
|
||||
|
||||
if (readinessFilter.value !== 'all') {
|
||||
list = list.filter((candidate) => {
|
||||
if (readinessFilter.value === 'needs_decision') {
|
||||
return candidate.acceptanceStatus === 'open' || candidate.acceptanceStatus === 'contacted'
|
||||
}
|
||||
|
||||
if (readinessFilter.value === 'missing_clip') {
|
||||
return !hasUsableCompilation(candidate)
|
||||
}
|
||||
@@ -163,6 +179,75 @@ export function useAdminCandidateManager() {
|
||||
)
|
||||
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
|
||||
const editorIdentitySummary = computed(() => {
|
||||
const identityKey = editorIdentityKey(form, editingId.value, seasonDetail.value.candidates)
|
||||
if (!identityKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return candidateIdentitySummaries.value[identityKey] ?? {
|
||||
appearances: 0,
|
||||
acceptedAppearances: 0,
|
||||
winnerPlacements: 0,
|
||||
nominationTally: 0,
|
||||
}
|
||||
})
|
||||
const editorRuleNotices = computed<CandidateRuleNotice[]>(() => {
|
||||
if (!selectedSeasonId.value || !form.categoryId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const notices: CandidateRuleNotice[] = []
|
||||
const finalistsRule = workflowRules.value.find((rule) => rule.key === maxFinalistsRuleKey)
|
||||
const appearancesRule = workflowRules.value.find((rule) => rule.key === maxCandidateAppearancesRuleKey)
|
||||
const activeCandidates = seasonDetail.value.candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined')
|
||||
const currentCandidateId = typeof editingId.value === 'number' ? editingId.value : null
|
||||
|
||||
if (finalistsRule?.enabled) {
|
||||
const categoryCount = activeCandidates.filter((candidate) =>
|
||||
candidate.categoryId === form.categoryId && candidate.id !== currentCandidateId,
|
||||
).length + 1
|
||||
|
||||
if (categoryCount > finalistsRule.limit) {
|
||||
notices.push({
|
||||
mode: finalistsRule.mode === 'warn' ? 'warn' : 'block',
|
||||
message: `${categoryCount}/${finalistsRule.limit} finale Kandidat:innen in dieser Kategorie.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (appearancesRule?.enabled) {
|
||||
const identityKey = editorIdentityKey(form, editingId.value, seasonDetail.value.candidates)
|
||||
if (identityKey) {
|
||||
const appearanceCount = activeCandidates.filter((candidate) =>
|
||||
candidate.id !== currentCandidateId && candidateIdentityKey(candidate) === identityKey,
|
||||
).length + 1
|
||||
|
||||
if (appearanceCount > appearancesRule.limit) {
|
||||
notices.push({
|
||||
mode: appearancesRule.mode === 'warn' ? 'warn' : 'block',
|
||||
message: `${appearanceCount}/${appearancesRule.limit} Kandidaturen für diese Person.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return notices
|
||||
})
|
||||
|
||||
function applyRouteFilters() {
|
||||
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
|
||||
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
|
||||
const rawReadiness = Array.isArray(route.query.readiness) ? route.query.readiness[0] : route.query.readiness
|
||||
|
||||
search.value = typeof rawQuery === 'string' ? rawQuery : ''
|
||||
|
||||
const parsedCategoryId = Number(rawCategoryId)
|
||||
categoryFilter.value = Number.isFinite(parsedCategoryId) && parsedCategoryId > 0 ? parsedCategoryId : null
|
||||
|
||||
const nextReadiness = typeof rawReadiness === 'string' ? rawReadiness : 'all'
|
||||
readinessFilter.value = readinessFilterOptions.some((option) => option.value === nextReadiness) ? nextReadiness : 'all'
|
||||
}
|
||||
|
||||
watch([search, categoryFilter, readinessFilter, () => seasonDetail.value.candidates.length], () => {
|
||||
page.value = 1
|
||||
@@ -174,6 +259,14 @@ export function useAdminCandidateManager() {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.query.q, route.query.categoryId, route.query.readiness] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
categoryFilter.value = null
|
||||
@@ -304,6 +397,7 @@ export function useAdminCandidateManager() {
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
candidateWorkflowNotices,
|
||||
candidateIdentitySummaries,
|
||||
duplicateCandidateCount,
|
||||
acceptedCandidateCount,
|
||||
clipReadyCount,
|
||||
@@ -319,6 +413,8 @@ export function useAdminCandidateManager() {
|
||||
canSave,
|
||||
candidatePlatformOptions,
|
||||
selectedPlatformValue,
|
||||
editorIdentitySummary,
|
||||
editorRuleNotices,
|
||||
acceptanceStatusOptions,
|
||||
clipEmbedStatusOptions,
|
||||
readinessFilterOptions,
|
||||
@@ -333,7 +429,7 @@ export function useAdminCandidateManager() {
|
||||
}
|
||||
|
||||
function buildCandidateWorkflowNotices(candidates: AdminCandidateItem[], rules: AdminWorkflowRule[]) {
|
||||
const notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>> = {}
|
||||
const notices: Record<number, CandidateRuleNotice[]> = {}
|
||||
const activeCandidates = candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined')
|
||||
const finalistsRule = rules.find((rule) => rule.key === maxFinalistsRuleKey)
|
||||
const appearancesRule = rules.find((rule) => rule.key === maxCandidateAppearancesRuleKey)
|
||||
@@ -371,7 +467,7 @@ function buildCandidateWorkflowNotices(candidates: AdminCandidateItem[], rules:
|
||||
}
|
||||
|
||||
function addCandidateNotice(
|
||||
notices: Record<number, Array<{ mode: 'warn' | 'block'; message: string }>>,
|
||||
notices: Record<number, CandidateRuleNotice[]>,
|
||||
candidateId: number,
|
||||
rule: AdminWorkflowRule,
|
||||
message: string,
|
||||
@@ -381,10 +477,34 @@ function addCandidateNotice(
|
||||
}
|
||||
|
||||
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
|
||||
if ('streamerIdentityId' in candidate && typeof candidate.streamerIdentityId === 'number') {
|
||||
return `identity:${candidate.streamerIdentityId}`
|
||||
}
|
||||
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
|
||||
}
|
||||
|
||||
function editorIdentityKey(
|
||||
form: Pick<CandidateForm, 'displayName' | 'channelSlug'>,
|
||||
editingId: number | 'new' | null,
|
||||
candidates: AdminCandidateItem[],
|
||||
) {
|
||||
if (typeof editingId === 'number') {
|
||||
const existing = candidates.find((candidate) => candidate.id === editingId)
|
||||
if (existing?.streamerIdentityId) {
|
||||
return `identity:${existing.streamerIdentityId}`
|
||||
}
|
||||
}
|
||||
|
||||
const channel = form.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||
if (channel) {
|
||||
return `slug:${channel}`
|
||||
}
|
||||
|
||||
const name = form.displayName.trim().toLowerCase()
|
||||
return name ? `name:${name}` : null
|
||||
}
|
||||
|
||||
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
|
||||
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
|
||||
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
|
||||
@@ -399,6 +519,34 @@ function hasUsableCompilation(candidate: AdminCandidateItem) {
|
||||
return Boolean(candidate.clipCompilationUrl?.trim()) && candidate.clipEmbedStatus !== 'blocked'
|
||||
}
|
||||
|
||||
function buildCandidateIdentitySummaries(candidates: AdminCandidateItem[], results: Array<{ candidateId: number }>) {
|
||||
const winnerCandidateIds = new Set(results.map((result) => result.candidateId))
|
||||
const summaries: Record<string, {
|
||||
appearances: number
|
||||
acceptedAppearances: number
|
||||
winnerPlacements: number
|
||||
nominationTally: number
|
||||
}> = {}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const key = candidateIdentityKey(candidate)
|
||||
const existing = summaries[key] ?? {
|
||||
appearances: 0,
|
||||
acceptedAppearances: 0,
|
||||
winnerPlacements: 0,
|
||||
nominationTally: 0,
|
||||
}
|
||||
|
||||
existing.appearances += candidate.acceptanceStatus === 'declined' ? 0 : 1
|
||||
existing.acceptedAppearances += candidate.acceptanceStatus === 'accepted' ? 1 : 0
|
||||
existing.winnerPlacements += winnerCandidateIds.has(candidate.id) ? 1 : 0
|
||||
existing.nominationTally = Math.max(existing.nominationTally, candidate.nominationTally ?? 0)
|
||||
summaries[key] = existing
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
function isValidOptionalUrl(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return true
|
||||
|
||||
@@ -1,182 +1,648 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCategoryItem } from '../../types/awards'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
const recommendedNominatorsRuleKey = 'recommended_nominators_per_subcategory'
|
||||
|
||||
type CategoryStatusFilter = 'all' | 'empty' | 'reviews' | 'thin'
|
||||
|
||||
interface CategoryForm {
|
||||
interface CategoryGroupForm {
|
||||
groupName: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}
|
||||
|
||||
interface CategoryWithState {
|
||||
id: number
|
||||
groupName: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
candidateCount: number
|
||||
pendingCount: number
|
||||
nominationCount: number
|
||||
nominatorCount: number
|
||||
}
|
||||
|
||||
export interface AdminSubcategoryDraft {
|
||||
name: string
|
||||
slug: string
|
||||
sortOrder: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
}
|
||||
|
||||
export interface AdminCategoryGroupSummary {
|
||||
name: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
candidateCount: number
|
||||
pendingCount: number
|
||||
nominationCount: number
|
||||
nominatorCount: number
|
||||
categories: CategoryWithState[]
|
||||
}
|
||||
|
||||
export type AdminCategoryOverviewTarget =
|
||||
| { type: 'group'; group: AdminCategoryGroupSummary }
|
||||
| { type: 'category'; group: AdminCategoryGroupSummary; category: CategoryWithState }
|
||||
|
||||
export function useAdminCategoryManager() {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
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 selectedGroupName = ref<string | null>(null)
|
||||
const focusedSubcategory = ref<string | null>(null)
|
||||
const saving = ref<string | 'new' | 'templates' | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const categoryToDelete = ref<AdminCategoryItem | null>(null)
|
||||
const groupToDelete = ref<AdminCategoryGroupSummary | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const editForms = reactive<Record<number, CategoryForm>>({})
|
||||
const newCategoryForm = reactive(createEmptyCategoryForm())
|
||||
const groupModalOpen = ref(false)
|
||||
const editingGroupName = ref<string | null>(null)
|
||||
const groupDraft = reactive(createEmptyGroupForm())
|
||||
const overviewTarget = ref<AdminCategoryOverviewTarget | null>(null)
|
||||
|
||||
const categoriesWithState = computed(() =>
|
||||
const subcategoryConfigModalOpen = ref(false)
|
||||
const subcategoryModalOpen = ref(false)
|
||||
const editingSubcategoryIndex = ref<number | null>(null)
|
||||
const subcategoryDraft = ref<AdminSubcategoryDraft>(createEmptySubcategoryDraft(1))
|
||||
const subcategoryDrafts = ref<AdminSubcategoryDraft[]>([])
|
||||
|
||||
const categoriesWithState = computed<CategoryWithState[]>(() =>
|
||||
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),
|
||||
.map((category) => {
|
||||
const categoryNominations = nominationsForCategory(category.id, seasonDetail.value)
|
||||
return {
|
||||
...category,
|
||||
candidateCount: category.candidateCount ?? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||
pendingCount: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
nominationCount: categoryNominations.length,
|
||||
nominatorCount: uniqueNominatorCount(categoryNominations),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'de')),
|
||||
)
|
||||
const filteredCategories = computed(() => {
|
||||
|
||||
const categoryGroups = computed<AdminCategoryGroupSummary[]>(() =>
|
||||
[...groupByName(categoriesWithState.value).values()]
|
||||
.map((categories) => {
|
||||
const sorted = [...categories].sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'de'))
|
||||
const first = sorted[0]
|
||||
return {
|
||||
name: first.groupName.trim(),
|
||||
description: first.description,
|
||||
sortOrder: sorted[0]?.sortOrder ?? 1,
|
||||
maxNomineesPerUser: first.maxNomineesPerUser,
|
||||
candidateCount: sorted.reduce((sum, category) => sum + category.candidateCount, 0),
|
||||
pendingCount: sorted.reduce((sum, category) => sum + category.pendingCount, 0),
|
||||
nominationCount: sorted.reduce((sum, category) => sum + category.nominationCount, 0),
|
||||
nominatorCount: uniqueNominatorCount(sorted.flatMap((category) => nominationsForCategory(category.id, seasonDetail.value))),
|
||||
categories: sorted,
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'de')),
|
||||
)
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return categoriesWithState.value.filter((category) => {
|
||||
return categoryGroups.value.filter((group) => {
|
||||
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]
|
||||
statusFilter.value === 'all'
|
||||
|| (statusFilter.value === 'empty' && group.categories.some((category) => category.candidateCount === 0))
|
||||
|| (statusFilter.value === 'reviews' && group.pendingCount > 0)
|
||||
|| (statusFilter.value === 'thin' && group.categories.some((category) => category.candidateCount > 0 && category.candidateCount < Math.max(2, category.maxNomineesPerUser)))
|
||||
const matchesSearch = !search || [
|
||||
group.name,
|
||||
group.description,
|
||||
...group.categories.flatMap((category) => [category.name, category.slug, formatViewerRange(category.viewerRangeMin, category.viewerRangeMax)]),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
})
|
||||
const selectedCategory = computed(() =>
|
||||
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
|
||||
|
||||
const selectedGroup = computed(() =>
|
||||
filteredGroups.value.find((group) => group.name === selectedGroupName.value)
|
||||
?? filteredGroups.value[0]
|
||||
?? null,
|
||||
)
|
||||
|
||||
const categoryStats = computed(() => [
|
||||
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Hauptkategorien', value: categoryGroups.value.length },
|
||||
{ label: 'Unterkategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
|
||||
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
|
||||
])
|
||||
const recommendedNominatorTarget = computed(() => {
|
||||
const configuredRule = store.adminWorkflowRules.rules.find((rule) => rule.key === recommendedNominatorsRuleKey)
|
||||
if (!configuredRule) {
|
||||
return 4
|
||||
}
|
||||
|
||||
return configuredRule.enabled ? Math.max(1, configuredRule.limit) : null
|
||||
})
|
||||
|
||||
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 },
|
||||
{ key: 'all' as const, label: 'Alle', count: categoryGroups.value.length },
|
||||
{ key: 'empty' as const, label: 'Mit Lücken', count: categoryGroups.value.filter((group) => group.categories.some((category) => category.candidateCount === 0)).length },
|
||||
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoryGroups.value.filter((group) => group.pendingCount > 0).length },
|
||||
{ key: 'thin' as const, label: 'Dünn besetzt', count: categoryGroups.value.filter((group) => group.categories.some((category) => category.candidateCount > 0 && category.candidateCount < Math.max(2, category.maxNomineesPerUser))).length },
|
||||
])
|
||||
|
||||
const activeSubcategoryTemplates = computed<AdminSubcategoryDraft[]>(() => {
|
||||
if (seasonDetail.value.subcategoryTemplates.length > 0) {
|
||||
return seasonDetail.value.subcategoryTemplates
|
||||
.map((template) => ({
|
||||
name: template.name,
|
||||
slug: template.slug,
|
||||
sortOrder: template.sortOrder,
|
||||
viewerRangeMin: template.viewerRangeMin,
|
||||
viewerRangeMax: template.viewerRangeMax,
|
||||
}))
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, 'de'))
|
||||
}
|
||||
|
||||
const distinct = new Map<string, AdminSubcategoryDraft>()
|
||||
for (const category of categoriesWithState.value) {
|
||||
const key = [category.name, category.viewerRangeMin ?? '', category.viewerRangeMax ?? ''].join('|')
|
||||
if (!distinct.has(key)) {
|
||||
distinct.set(key, {
|
||||
name: category.name,
|
||||
slug: extractTemplateSlug(category.groupName, category.slug),
|
||||
sortOrder: distinct.size + 1,
|
||||
viewerRangeMin: category.viewerRangeMin,
|
||||
viewerRangeMax: category.viewerRangeMax,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...distinct.values()]
|
||||
})
|
||||
const focusedSubcategoryTemplate = computed(() => {
|
||||
if (!focusedSubcategory.value) return null
|
||||
const normalizedFocus = normalizeSubcategoryFocus(focusedSubcategory.value)
|
||||
return activeSubcategoryTemplates.value.find((template) =>
|
||||
normalizeSubcategoryFocus(template.slug) === normalizedFocus
|
||||
|| normalizeSubcategoryFocus(template.name) === normalizedFocus,
|
||||
) ?? null
|
||||
})
|
||||
|
||||
const canSaveSubcategory = computed(() => validateSubcategoryDraft(subcategoryDraft.value) === null)
|
||||
const canSaveSubcategoryConfig = computed(() =>
|
||||
subcategoryDrafts.value.length > 0
|
||||
&& subcategoryDrafts.value.every((draft) => validateSubcategoryDraft(draft) === null)
|
||||
&& hasUniqueValues(subcategoryDrafts.value.map((draft) => draft.name))
|
||||
&& hasUniqueValues(subcategoryDrafts.value.map((draft) => draft.slug)),
|
||||
)
|
||||
const canCreateGroup = computed(() => selectedSeasonId.value !== null && activeSubcategoryTemplates.value.length > 0)
|
||||
const canSaveGroup = computed(() => validateGroupForm(groupDraft) === null && (editingGroupName.value !== null || canCreateGroup.value))
|
||||
const groupModalTitle = computed(() =>
|
||||
editingGroupName.value === null ? 'Hauptkategorie anlegen' : 'Hauptkategorie bearbeiten',
|
||||
)
|
||||
const subcategoryModalTitle = computed(() =>
|
||||
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufuegen' : 'Unterkategorie bearbeiten',
|
||||
)
|
||||
|
||||
const overviewCategories = computed(() => {
|
||||
if (!overviewTarget.value) return []
|
||||
return overviewTarget.value.type === 'group'
|
||||
? overviewTarget.value.group.categories
|
||||
: [overviewTarget.value.category]
|
||||
})
|
||||
const overviewCategoryIds = computed(() => new Set(overviewCategories.value.map((category) => category.id)))
|
||||
const overviewCandidates = computed<AdminCandidateItem[]>(() =>
|
||||
seasonDetail.value.candidates.filter((candidate) => overviewCategoryIds.value.has(candidate.categoryId)),
|
||||
)
|
||||
const overviewNominations = computed<AdminNominationReviewItem[]>(() => {
|
||||
if (!overviewTarget.value) return []
|
||||
const groupName = overviewTarget.value.group.name
|
||||
return [...seasonDetail.value.pendingNominations, ...seasonDetail.value.reviewedNominations]
|
||||
.filter((nomination) =>
|
||||
(nomination.categoryId !== null && overviewCategoryIds.value.has(nomination.categoryId))
|
||||
|| (nomination.suggestedCategoryId !== null && overviewCategoryIds.value.has(nomination.suggestedCategoryId))
|
||||
|| (overviewTarget.value?.type === 'group'
|
||||
&& nomination.categoryId === null
|
||||
&& nomination.categoryGroupName.trim().toLowerCase() === groupName.trim().toLowerCase()),
|
||||
)
|
||||
})
|
||||
const overviewTitle = computed(() => {
|
||||
if (!overviewTarget.value) return ''
|
||||
return overviewTarget.value.type === 'group'
|
||||
? overviewTarget.value.group.name
|
||||
: `${overviewTarget.value.group.name} · ${overviewTarget.value.category.name}`
|
||||
})
|
||||
const overviewSubtitle = computed(() => {
|
||||
if (!overviewTarget.value) return ''
|
||||
return overviewTarget.value.type === 'group'
|
||||
? `${overviewCategories.value.length} Unterkategorien · ${overviewCandidates.value.length} Kandidaten · ${overviewNominations.value.length} Nominierungen`
|
||||
: `${formatViewerRange(overviewTarget.value.category.viewerRangeMin, overviewTarget.value.category.viewerRangeMax)} · ${overviewCandidates.value.length} Kandidaten · ${overviewNominations.value.length} Nominierungen`
|
||||
})
|
||||
|
||||
function applyRouteFilters() {
|
||||
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
|
||||
const rawStatus = Array.isArray(route.query.status) ? route.query.status[0] : route.query.status
|
||||
const rawGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
|
||||
const rawSubcategory = Array.isArray(route.query.subcategory) ? route.query.subcategory[0] : route.query.subcategory
|
||||
|
||||
query.value = typeof rawQuery === 'string' ? rawQuery : ''
|
||||
statusFilter.value = isCategoryStatusFilter(rawStatus) ? rawStatus : 'all'
|
||||
selectedGroupName.value = typeof rawGroup === 'string' && rawGroup.trim() ? rawGroup : null
|
||||
focusedSubcategory.value = typeof rawSubcategory === 'string' && rawSubcategory.trim() ? rawSubcategory.trim() : null
|
||||
}
|
||||
|
||||
watch(categoryGroups, (groups) => {
|
||||
if (!groups.some((group) => group.name === selectedGroupName.value)) {
|
||||
selectedGroupName.value = groups[0]?.name ?? null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(filteredGroups, (groups) => {
|
||||
if (!groups.some((group) => group.name === selectedGroupName.value)) {
|
||||
selectedGroupName.value = groups[0]?.name ?? null
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
() => [route.query.q, route.query.status, route.query.group, route.query.subcategory] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(filteredCategories, (categories) => {
|
||||
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = categories[0]?.id ?? null
|
||||
}
|
||||
})
|
||||
function isFocusedSubcategory(nameOrSlug: string) {
|
||||
if (!focusedSubcategoryTemplate.value) return false
|
||||
const normalizedValue = normalizeSubcategoryFocus(nameOrSlug)
|
||||
return normalizedValue === normalizeSubcategoryFocus(focusedSubcategoryTemplate.value.slug)
|
||||
|| normalizedValue === normalizeSubcategoryFocus(focusedSubcategoryTemplate.value.name)
|
||||
}
|
||||
|
||||
async function saveCategory(categoryId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
saving.value = categoryId
|
||||
function openSubcategoryConfigModal() {
|
||||
adminError.value = ''
|
||||
subcategoryDrafts.value = activeSubcategoryTemplates.value.map(cloneSubcategoryDraft)
|
||||
subcategoryConfigModalOpen.value = true
|
||||
subcategoryModalOpen.value = false
|
||||
editingSubcategoryIndex.value = null
|
||||
}
|
||||
|
||||
function openCreateGroupModal() {
|
||||
adminError.value = ''
|
||||
editingGroupName.value = null
|
||||
Object.assign(groupDraft, createEmptyGroupForm(categoryGroups.value.length + 1))
|
||||
groupModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditGroupModal(group: AdminCategoryGroupSummary) {
|
||||
adminError.value = ''
|
||||
editingGroupName.value = group.name
|
||||
Object.assign(groupDraft, createGroupForm(group))
|
||||
groupModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeGroupModal() {
|
||||
groupModalOpen.value = false
|
||||
editingGroupName.value = null
|
||||
Object.assign(groupDraft, createEmptyGroupForm(categoryGroups.value.length + 1))
|
||||
}
|
||||
|
||||
function closeSubcategoryConfigModal() {
|
||||
subcategoryConfigModalOpen.value = false
|
||||
subcategoryModalOpen.value = false
|
||||
editingSubcategoryIndex.value = null
|
||||
}
|
||||
|
||||
function openCreateSubcategoryModal() {
|
||||
adminError.value = ''
|
||||
editingSubcategoryIndex.value = null
|
||||
subcategoryDraft.value = createEmptySubcategoryDraft(nextDraftSortOrder(subcategoryDrafts.value))
|
||||
subcategoryModalOpen.value = true
|
||||
}
|
||||
|
||||
function openEditSubcategoryModal(index: number) {
|
||||
const template = subcategoryDrafts.value[index]
|
||||
if (!template) return
|
||||
adminError.value = ''
|
||||
editingSubcategoryIndex.value = index
|
||||
subcategoryDraft.value = cloneSubcategoryDraft(template)
|
||||
subcategoryModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeSubcategoryModal() {
|
||||
subcategoryModalOpen.value = false
|
||||
editingSubcategoryIndex.value = null
|
||||
}
|
||||
|
||||
function fillSubcategorySlug() {
|
||||
subcategoryDraft.value.slug = slugify(subcategoryDraft.value.name) || `unterkategorie-${nextDraftSortOrder(subcategoryDrafts.value)}`
|
||||
}
|
||||
|
||||
function saveSubcategoryDraft() {
|
||||
const validationMessage = validateSubcategoryDraft(subcategoryDraft.value)
|
||||
if (validationMessage) {
|
||||
adminError.value = validationMessage
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = normalizeDraft(subcategoryDraft.value)
|
||||
if (editingSubcategoryIndex.value === null) {
|
||||
subcategoryDrafts.value = [...subcategoryDrafts.value, normalized]
|
||||
} else {
|
||||
subcategoryDrafts.value = subcategoryDrafts.value.map((item, index) => index === editingSubcategoryIndex.value ? normalized : item)
|
||||
}
|
||||
|
||||
closeSubcategoryModal()
|
||||
}
|
||||
|
||||
function removeDraftSubcategory(index: number) {
|
||||
subcategoryDrafts.value = subcategoryDrafts.value
|
||||
.filter((_, itemIndex) => itemIndex !== index)
|
||||
.map((draft, itemIndex) => ({ ...draft, sortOrder: itemIndex + 1 }))
|
||||
}
|
||||
|
||||
async function saveSubcategoryConfig() {
|
||||
if (!selectedSeasonId.value || !canSaveSubcategoryConfig.value) return
|
||||
|
||||
saving.value = 'templates'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
|
||||
adminMessage.value = 'Kategorie gespeichert.'
|
||||
await store.updateAdminSeasonSubcategoryTemplates(selectedSeasonId.value, {
|
||||
templates: subcategoryDrafts.value
|
||||
.map(normalizeDraft)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((draft, index) => ({ ...draft, sortOrder: index + 1 })),
|
||||
})
|
||||
adminMessage.value = 'Unterkategorien wurden fuer alle Hauptkategorien aktualisiert.'
|
||||
closeSubcategoryConfigModal()
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
|
||||
adminError.value = error instanceof Error ? error.message : 'Unterkategorien konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
if (!selectedSeasonId.value) return
|
||||
saving.value = 'new'
|
||||
async function saveGroupModal() {
|
||||
if (!selectedSeasonId.value || !canSaveGroup.value) return
|
||||
|
||||
const targetGroupName = editingGroupName.value
|
||||
saving.value = targetGroupName ?? '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))
|
||||
const payload = {
|
||||
groupName: groupDraft.groupName.trim(),
|
||||
description: groupDraft.description.trim(),
|
||||
sortOrder: normalizePositiveInteger(groupDraft.sortOrder, 1),
|
||||
maxNomineesPerUser: normalizePositiveInteger(groupDraft.maxNomineesPerUser, 3),
|
||||
}
|
||||
if (targetGroupName) {
|
||||
await store.updateAdminCategoryGroup(selectedSeasonId.value, targetGroupName, payload)
|
||||
adminMessage.value = 'Hauptkategorie gespeichert.'
|
||||
} else {
|
||||
await store.createAdminCategoryGroup(selectedSeasonId.value, payload)
|
||||
adminMessage.value = 'Hauptkategorie angelegt.'
|
||||
}
|
||||
selectedGroupName.value = payload.groupName
|
||||
closeGroupModal()
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||
adminError.value = error instanceof Error ? error.message : targetGroupName
|
||||
? 'Hauptkategorie konnte nicht gespeichert werden.'
|
||||
: 'Hauptkategorie konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function fillNewSlug() {
|
||||
newCategoryForm.slug = slugify(newCategoryForm.name)
|
||||
}
|
||||
async function confirmDeleteGroup() {
|
||||
if (!selectedSeasonId.value || !groupToDelete.value) return
|
||||
|
||||
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
|
||||
await store.deleteAdminCategoryGroup(selectedSeasonId.value, groupToDelete.value.name)
|
||||
adminMessage.value = `Hauptkategorie "${groupToDelete.value.name}" wurde geloescht.`
|
||||
groupToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
adminError.value = error instanceof Error ? error.message : 'Loeschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openGroupOverview(group: AdminCategoryGroupSummary) {
|
||||
overviewTarget.value = { type: 'group', group }
|
||||
}
|
||||
|
||||
function openCategoryOverview(group: AdminCategoryGroupSummary, category: CategoryWithState) {
|
||||
overviewTarget.value = { type: 'category', group, category }
|
||||
}
|
||||
|
||||
return {
|
||||
selectedSeasonId,
|
||||
query,
|
||||
statusFilter,
|
||||
selectedCategoryId,
|
||||
selectedGroupName,
|
||||
focusedSubcategory,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
editForms,
|
||||
newCategoryForm,
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
groupModalOpen,
|
||||
editingGroupName,
|
||||
groupDraft,
|
||||
overviewTarget,
|
||||
overviewTitle,
|
||||
overviewSubtitle,
|
||||
overviewCategories,
|
||||
overviewCandidates,
|
||||
overviewNominations,
|
||||
filteredGroups,
|
||||
selectedGroup,
|
||||
categoryStats,
|
||||
statusFilters,
|
||||
categoryToDelete,
|
||||
activeSubcategoryTemplates,
|
||||
focusedSubcategoryTemplate,
|
||||
subcategoryConfigModalOpen,
|
||||
subcategoryModalOpen,
|
||||
subcategoryDraft,
|
||||
subcategoryDrafts,
|
||||
subcategoryModalTitle,
|
||||
canSaveSubcategory,
|
||||
canSaveSubcategoryConfig,
|
||||
canCreateGroup,
|
||||
canSaveGroup,
|
||||
groupModalTitle,
|
||||
groupToDelete,
|
||||
deleting,
|
||||
saveCategory,
|
||||
createCategory,
|
||||
fillNewSlug,
|
||||
confirmDeleteCategory,
|
||||
openCreateGroupModal,
|
||||
openEditGroupModal,
|
||||
closeGroupModal,
|
||||
openSubcategoryConfigModal,
|
||||
closeSubcategoryConfigModal,
|
||||
openCreateSubcategoryModal,
|
||||
openEditSubcategoryModal,
|
||||
closeSubcategoryModal,
|
||||
fillSubcategorySlug,
|
||||
saveSubcategoryDraft,
|
||||
removeDraftSubcategory,
|
||||
saveSubcategoryConfig,
|
||||
saveGroupModal,
|
||||
confirmDeleteGroup,
|
||||
openGroupOverview,
|
||||
openCategoryOverview,
|
||||
isFocusedSubcategory,
|
||||
formatViewerRange,
|
||||
recommendedNominatorTarget,
|
||||
}
|
||||
}
|
||||
|
||||
function createCategoryForm(category: AdminCategoryItem): CategoryForm
|
||||
function createCategoryForm(category?: AdminCategoryItem, sortOrder?: number): CategoryForm
|
||||
function createCategoryForm(category?: AdminCategoryItem, sortOrder = 1): CategoryForm {
|
||||
function groupByName(categories: CategoryWithState[]) {
|
||||
const groups = new Map<string, CategoryWithState[]>()
|
||||
for (const category of categories) {
|
||||
const key = category.groupName.trim() || 'Ohne Gruppe'
|
||||
const current = groups.get(key) ?? []
|
||||
current.push(category)
|
||||
groups.set(key, current)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function nominationsForCategory(categoryId: number, source?: {
|
||||
pendingNominations: AdminNominationReviewItem[]
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
}) {
|
||||
const allNominations = source
|
||||
? [...source.pendingNominations, ...source.reviewedNominations]
|
||||
: []
|
||||
|
||||
if (source) {
|
||||
return allNominations.filter((nomination) =>
|
||||
(nomination.categoryId !== null && nomination.categoryId === categoryId)
|
||||
|| (nomination.suggestedCategoryId !== null && nomination.suggestedCategoryId === categoryId),
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function uniqueNominatorCount(nominations: AdminNominationReviewItem[]) {
|
||||
return new Set(nominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean)).size
|
||||
}
|
||||
|
||||
function isCategoryStatusFilter(value: unknown): value is CategoryStatusFilter {
|
||||
return value === 'all' || value === 'empty' || value === 'reviews' || value === 'thin'
|
||||
}
|
||||
|
||||
function normalizeSubcategoryFocus(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function createGroupForm(group?: AdminCategoryGroupSummary, sortOrder = 1): CategoryGroupForm {
|
||||
return {
|
||||
groupName: category?.groupName ?? '',
|
||||
name: category?.name ?? '',
|
||||
slug: category?.slug ?? '',
|
||||
description: category?.description ?? '',
|
||||
sortOrder: category?.sortOrder ?? sortOrder,
|
||||
maxNomineesPerUser: category?.maxNomineesPerUser ?? 3,
|
||||
groupName: group?.name ?? '',
|
||||
description: group?.description ?? '',
|
||||
sortOrder: group?.sortOrder ?? sortOrder,
|
||||
maxNomineesPerUser: group?.maxNomineesPerUser ?? 3,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCategoryForm(sortOrder = 1): CategoryForm {
|
||||
return createCategoryForm(undefined, sortOrder)
|
||||
function createEmptyGroupForm(sortOrder = 1): CategoryGroupForm {
|
||||
return createGroupForm(undefined, sortOrder)
|
||||
}
|
||||
|
||||
function validateGroupForm(form: CategoryGroupForm) {
|
||||
if (!form.groupName.trim()) return 'Der Name der Hauptkategorie ist erforderlich.'
|
||||
if (form.groupName.trim().length > 80) return 'Der Name der Hauptkategorie muss unter 80 Zeichen bleiben.'
|
||||
if (form.description.trim().length > 400) return 'Die Beschreibung muss unter 400 Zeichen bleiben.'
|
||||
if (normalizePositiveInteger(form.sortOrder, 1) > 200) return 'Die Reihenfolge muss zwischen 1 und 200 liegen.'
|
||||
if (normalizePositiveInteger(form.maxNomineesPerUser, 3) > 10) return 'Das Nominierungs-Limit muss zwischen 1 und 10 liegen.'
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: number, fallback: number) {
|
||||
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : fallback
|
||||
}
|
||||
|
||||
function createEmptySubcategoryDraft(sortOrder: number): AdminSubcategoryDraft {
|
||||
return {
|
||||
name: '',
|
||||
slug: `unterkategorie-${sortOrder}`,
|
||||
sortOrder,
|
||||
viewerRangeMin: null,
|
||||
viewerRangeMax: null,
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSubcategoryDraft(draft: AdminSubcategoryDraft): AdminSubcategoryDraft {
|
||||
return {
|
||||
name: draft.name,
|
||||
slug: draft.slug,
|
||||
sortOrder: draft.sortOrder,
|
||||
viewerRangeMin: draft.viewerRangeMin,
|
||||
viewerRangeMax: draft.viewerRangeMax,
|
||||
}
|
||||
}
|
||||
|
||||
function nextDraftSortOrder(items: AdminSubcategoryDraft[]) {
|
||||
return items.reduce((max, item) => Math.max(max, item.sortOrder), 0) + 1
|
||||
}
|
||||
|
||||
function validateSubcategoryDraft(draft: AdminSubcategoryDraft) {
|
||||
const normalized = normalizeDraft(draft)
|
||||
if (!normalized.name) return 'Der Name der Unterkategorie ist erforderlich.'
|
||||
if (!normalized.slug) return 'Der Slug der Unterkategorie ist erforderlich.'
|
||||
if (normalized.viewerRangeMin !== null && normalized.viewerRangeMin < 0) return 'Viewer-Range darf nicht negativ sein.'
|
||||
if (normalized.viewerRangeMax !== null && normalized.viewerRangeMax < 0) return 'Viewer-Range darf nicht negativ sein.'
|
||||
if (normalized.viewerRangeMin !== null && normalized.viewerRangeMax !== null && normalized.viewerRangeMax < normalized.viewerRangeMin) {
|
||||
return 'Viewer-Range Ende muss groesser oder gleich dem Start sein.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeDraft(draft: AdminSubcategoryDraft): AdminSubcategoryDraft {
|
||||
return {
|
||||
name: draft.name.trim(),
|
||||
slug: slugify(draft.slug) || slugify(draft.name),
|
||||
sortOrder: Number.isFinite(draft.sortOrder) ? Math.max(1, Math.round(draft.sortOrder)) : 1,
|
||||
viewerRangeMin: normalizeNullableNumber(draft.viewerRangeMin),
|
||||
viewerRangeMax: normalizeNullableNumber(draft.viewerRangeMax),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNullableNumber(value: number | null) {
|
||||
return value === null || Number.isNaN(value) ? null : Math.max(0, Math.round(value))
|
||||
}
|
||||
|
||||
function hasUniqueValues(values: string[]) {
|
||||
const normalized = values.map((value) => value.trim().toLowerCase()).filter(Boolean)
|
||||
return new Set(normalized).size === normalized.length
|
||||
}
|
||||
|
||||
function extractTemplateSlug(groupName: string, slug: string) {
|
||||
const groupSlug = slugify(groupName)
|
||||
const normalizedSlug = slug.trim().toLowerCase()
|
||||
const prefix = groupSlug ? `${groupSlug}-` : ''
|
||||
return prefix && normalizedSlug.startsWith(prefix) ? normalizedSlug.slice(prefix.length) : normalizedSlug
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
@@ -188,3 +654,10 @@ function slugify(value: string) {
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export function formatViewerRange(min: number | null, max: number | null) {
|
||||
if (min !== null && max !== null) return `${min} - ${max} Viewer`
|
||||
if (min !== null) return `ab ${min} Viewer`
|
||||
if (max !== null) return `bis ${max} Viewer`
|
||||
return 'ohne Viewer-Range'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, Film, Layers3, PlayCircle } from '@lucide/vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { buildClipEmbed, type ClipEmbed } from '../../lib/clipEmbeds'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
@@ -11,6 +12,7 @@ type ClipPlatformFilter = 'all' | string
|
||||
|
||||
export function useAdminClipManager() {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const query = ref('')
|
||||
const statusFilter = ref<ClipStatusFilter>('all')
|
||||
const platformFilter = ref<ClipPlatformFilter>('all')
|
||||
@@ -126,6 +128,20 @@ export function useAdminClipManager() {
|
||||
})),
|
||||
])
|
||||
|
||||
function applyRouteFilters() {
|
||||
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
|
||||
const rawStatus = Array.isArray(route.query.status) ? route.query.status[0] : route.query.status
|
||||
const rawPlatform = Array.isArray(route.query.platform) ? route.query.platform[0] : route.query.platform
|
||||
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
|
||||
|
||||
query.value = typeof rawQuery === 'string' ? rawQuery : ''
|
||||
statusFilter.value = isClipStatusFilter(rawStatus) ? rawStatus : 'all'
|
||||
platformFilter.value = typeof rawPlatform === 'string' && rawPlatform.trim() ? rawPlatform : 'all'
|
||||
|
||||
const normalizedCategoryId = typeof rawCategoryId === 'string' && rawCategoryId.trim() ? rawCategoryId : 'all'
|
||||
categoryFilter.value = categoryFilters.value.some((filter) => filter.id === normalizedCategoryId) ? normalizedCategoryId : 'all'
|
||||
}
|
||||
|
||||
watch(submissions, (items) => {
|
||||
for (const clip of items) {
|
||||
if (reviewNotes[clip.id] === undefined) {
|
||||
@@ -134,6 +150,14 @@ export function useAdminClipManager() {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => [route.query.q, route.query.status, route.query.platform, route.query.categoryId] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function updateClipStatus(clip: AdminClipSubmissionItem, status: ClipReviewStatus) {
|
||||
if (!selectedSeasonId.value) return
|
||||
statusSaving.value = clip.id
|
||||
@@ -239,6 +263,10 @@ function matchesStatusFilter(status: string, filter: ClipStatusFilter) {
|
||||
return filter === 'all' || status === filter || (filter === 'reviewed' && status !== 'pending')
|
||||
}
|
||||
|
||||
function isClipStatusFilter(value: unknown): value is ClipStatusFilter {
|
||||
return value === 'all' || value === 'pending' || value === 'reviewed'
|
||||
}
|
||||
|
||||
function normalizeClipUrl(clipUrl: string) {
|
||||
return clipUrl.trim().toLowerCase()
|
||||
}
|
||||
|
||||
@@ -7,13 +7,27 @@ import {
|
||||
} from '../../lib/socialIcons'
|
||||
import { privacyContentForStorage, privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { ShowactFormField } from '../../types/awards/showactForm'
|
||||
import { DEFAULT_SHOWACT_SCHEMA } from '../../types/awards/showactForm'
|
||||
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
||||
|
||||
function parseShowactSchema(json: string): ShowactFormField[] {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed as ShowactFormField[]
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return DEFAULT_SHOWACT_SCHEMA
|
||||
}
|
||||
|
||||
function createEmptyForm(): AdminContentForm {
|
||||
return {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
newsletterUrl: '',
|
||||
shareXUrl: '',
|
||||
shareDiscordUrl: '',
|
||||
privacyEmail: '',
|
||||
privacyPolicyContent: '',
|
||||
imprintUrl: '',
|
||||
@@ -26,6 +40,7 @@ function createEmptyForm(): AdminContentForm {
|
||||
showactsContent: '',
|
||||
socialLinks: [],
|
||||
faq: [],
|
||||
showactFormSchema: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +92,8 @@ export function useAdminContentManager() {
|
||||
form.hostDisplayName = settings.hostDisplayName
|
||||
form.hostTagline = settings.hostTagline
|
||||
form.newsletterUrl = settings.newsletterUrl
|
||||
form.shareXUrl = settings.shareXUrl
|
||||
form.shareDiscordUrl = settings.shareDiscordUrl
|
||||
form.privacyEmail = settings.privacyEmail
|
||||
form.privacyPolicyContent = settings.privacyPolicyContent
|
||||
form.imprintUrl = settings.imprintUrl
|
||||
@@ -99,6 +116,7 @@ export function useAdminContentManager() {
|
||||
question: item.question ?? '',
|
||||
answer: item.answer ?? '',
|
||||
}))
|
||||
form.showactFormSchema = parseShowactSchema(settings.showactFormSchemaJson ?? '[]')
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
@@ -245,6 +263,8 @@ export function useAdminContentManager() {
|
||||
hostDisplayName: form.hostDisplayName,
|
||||
hostTagline: form.hostTagline,
|
||||
newsletterUrl: form.newsletterUrl,
|
||||
shareXUrl: form.shareXUrl,
|
||||
shareDiscordUrl: form.shareDiscordUrl,
|
||||
privacyEmail: form.privacyEmail,
|
||||
privacyPolicyContent: privacyContentForStorage(form.privacyPolicyContent),
|
||||
imprintUrl: form.imprintUrl,
|
||||
@@ -255,6 +275,7 @@ export function useAdminContentManager() {
|
||||
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
|
||||
showactsUrl: form.showactsUrl,
|
||||
showactsContent: privacyContentForStorage(form.showactsContent),
|
||||
showactFormSchemaJson: JSON.stringify(form.showactFormSchema),
|
||||
socialLinks: form.socialLinks
|
||||
.map((item) => ({
|
||||
label: item.label.trim(),
|
||||
|
||||
@@ -149,7 +149,9 @@ export function useAdminDashboardOverview() {
|
||||
{
|
||||
label: 'Kandidatenbasis',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
to: '/admin/candidates',
|
||||
to: store.adminSeasonDetail.candidates.length === 0
|
||||
? '/admin/categories?status=empty'
|
||||
: '/admin/candidates',
|
||||
hint: 'Kandidaten und Plattformen schnell prüfen',
|
||||
icon: Users,
|
||||
tone: 'emerald',
|
||||
@@ -167,14 +169,18 @@ export function useAdminDashboardOverview() {
|
||||
{
|
||||
label: 'Kategorien ohne Kandidaten',
|
||||
value: categoriesWithoutCandidates.length,
|
||||
to: '/admin/categories',
|
||||
to: categoriesWithoutCandidates.length > 0
|
||||
? `/admin/categories?group=${encodeURIComponent(categoriesWithoutCandidates[0]!.groupName)}`
|
||||
: '/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',
|
||||
to: categoriesWithReviews.length > 0
|
||||
? `/admin/nominations?review=1&categoryId=${categoriesWithReviews[0]!.id}&status=heavy`
|
||||
: '/admin/nominations?review=1',
|
||||
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
|
||||
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ export function useAdminOperationalSettings() {
|
||||
twitchClientId: '',
|
||||
twitchRedirectUri: '',
|
||||
twitchScope: '',
|
||||
sessionIdleTimeoutHours: 3,
|
||||
maintenanceModeEnabled: false,
|
||||
maintenanceTitle: fallbackMaintenanceTitle,
|
||||
maintenanceMessage: fallbackMaintenanceMessage,
|
||||
@@ -89,6 +90,12 @@ export function useAdminOperationalSettings() {
|
||||
? demoCredentialsComplete.value ? 'warning' : 'danger'
|
||||
: 'good',
|
||||
},
|
||||
{
|
||||
label: 'Session Timeout',
|
||||
value: `${operationalForm.sessionIdleTimeoutHours}h`,
|
||||
note: `Logout nach ${operationalForm.sessionIdleTimeoutHours} Stunden ohne Aktivitaet.`,
|
||||
tone: operationalForm.sessionIdleTimeoutHours >= 3 ? 'good' : 'danger',
|
||||
},
|
||||
{
|
||||
label: 'Wartungsmodus',
|
||||
value: operationalForm.maintenanceModeEnabled ? 'Aktiv' : 'Aus',
|
||||
@@ -126,6 +133,7 @@ export function useAdminOperationalSettings() {
|
||||
operationalForm.twitchClientId = response.twitchClientId
|
||||
operationalForm.twitchRedirectUri = response.twitchRedirectUri
|
||||
operationalForm.twitchScope = response.twitchScope
|
||||
operationalForm.sessionIdleTimeoutHours = response.sessionIdleTimeoutHours
|
||||
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
|
||||
operationalForm.maintenanceTitle = response.maintenanceTitle
|
||||
operationalForm.maintenanceMessage = response.maintenanceMessage
|
||||
@@ -148,6 +156,7 @@ export function useAdminOperationalSettings() {
|
||||
twitchClientId: operationalForm.twitchClientId,
|
||||
twitchRedirectUri: operationalForm.twitchRedirectUri,
|
||||
twitchScope: operationalForm.twitchScope,
|
||||
sessionIdleTimeoutHours: operationalForm.sessionIdleTimeoutHours,
|
||||
twitchClientSecretInput: twitchClientSecretInput.value,
|
||||
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
|
||||
maintenanceTitle: operationalForm.maintenanceTitle,
|
||||
@@ -208,6 +217,11 @@ export function useAdminOperationalSettings() {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!Number.isInteger(operationalForm.sessionIdleTimeoutHours) || operationalForm.sessionIdleTimeoutHours < 3) {
|
||||
operationalError.value = 'Session-Timeout muss mindestens 3 Stunden betragen.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -235,7 +249,7 @@ export function useAdminOperationalSettings() {
|
||||
twitchClientSecretInput.value = ''
|
||||
await loadOperationalSettings({ silent: true })
|
||||
clearSiteStatusCache()
|
||||
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
|
||||
operationalSuccess.value = 'Demo-Zugang, Session-Timeout und Wartungsmodus wurden gespeichert.'
|
||||
} catch (error) {
|
||||
operationalError.value = error instanceof ApiRequestError
|
||||
? error.message
|
||||
@@ -254,6 +268,7 @@ export function useAdminOperationalSettings() {
|
||||
operationalForm.twitchClientId,
|
||||
operationalForm.twitchRedirectUri,
|
||||
operationalForm.twitchScope,
|
||||
operationalForm.sessionIdleTimeoutHours,
|
||||
twitchClientSecretInput.value,
|
||||
operationalForm.maintenanceModeEnabled,
|
||||
operationalForm.maintenanceTitle,
|
||||
|
||||
@@ -40,6 +40,8 @@ export function useAdminOptionalFeatures() {
|
||||
clipAdminMenuVisible: optionalFeaturesForm.clipAdminMenuVisible,
|
||||
clipSubmissionDisabledMessage: normalizeDisabledMessage(optionalFeaturesForm.clipSubmissionDisabledMessage),
|
||||
showactApplicationsEnabled: store.adminOptionalFeatureSettings.showactApplicationsEnabled,
|
||||
showactApplicationStartsAt: store.adminOptionalFeatureSettings.showactApplicationStartsAt,
|
||||
showactApplicationEndsAt: store.adminOptionalFeatureSettings.showactApplicationEndsAt,
|
||||
showactApplicationDisabledMessage: store.adminOptionalFeatureSettings.showactApplicationDisabledMessage,
|
||||
sponsorsVisible: store.adminOptionalFeatureSettings.sponsorsVisible,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
export function useAdminReviewsManager() {
|
||||
export function useAdminReviewsManager(options?: {
|
||||
categoryIdsFilter?: () => number[] | null | undefined
|
||||
}) {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const reviewSaving = ref<number | null>(null)
|
||||
@@ -12,120 +15,166 @@ export function useAdminReviewsManager() {
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const reviewFilter = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const categoryFilter = ref<string | null>(null)
|
||||
const subcategoryFilter = ref<number | null>(null)
|
||||
const selectedNominationId = ref<number | null>(null)
|
||||
const isReviewModalOpen = ref(false)
|
||||
const reviewForms = reactive<Record<number, {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
categoryId: number | null
|
||||
reviewNote: string
|
||||
}>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||
const reviewGroups = computed(() => seasonDetail.value.pendingNominationGroups ?? [])
|
||||
const pendingNominationById = computed(() =>
|
||||
new Map(seasonDetail.value.pendingNominations.map((nomination) => [nomination.id, nomination])),
|
||||
)
|
||||
const tierCategoryFilterIds = computed(() =>
|
||||
(options?.categoryIdsFilter?.() ?? [])
|
||||
.filter((value): value is number => Number.isFinite(value) && value > 0),
|
||||
)
|
||||
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, extractNominationStreamUrl(nomination)]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)),
|
||||
const tierFilter = new Set(tierCategoryFilterIds.value)
|
||||
return reviewGroups.value.filter((nomination) =>
|
||||
(tierFilter.size === 0 || groupMatchesTierFilter(nomination, tierFilter, pendingNominationById.value)) &&
|
||||
(subcategoryFilter.value === null || groupMatchesTierFilter(nomination, new Set([subcategoryFilter.value]), pendingNominationById.value)) &&
|
||||
(!categoryFilter.value || nomination.categoryGroupName === categoryFilter.value) &&
|
||||
(!query || [
|
||||
nomination.categoryGroupName,
|
||||
nomination.displayName,
|
||||
nomination.resolvedChannel,
|
||||
nomination.resolvedPlatform,
|
||||
nomination.streamUrl,
|
||||
nomination.suggestedCategoryName,
|
||||
].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 },
|
||||
{ label: 'Hauptkategorien', value: new Set(seasonDetail.value.categories.map((category) => category.groupName)).size },
|
||||
{ label: 'Unterkategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Nominierungen', value: seasonDetail.value.pendingNominations.length + seasonDetail.value.reviewedNominations.length },
|
||||
{ label: 'Noch zu bearbeiten', value: seasonDetail.value.pendingNominationGroups.length },
|
||||
])
|
||||
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,
|
||||
[...new Set(reviewGroups.value.map((nomination) => nomination.categoryGroupName))]
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((groupName) => ({
|
||||
id: groupName,
|
||||
label: groupName,
|
||||
count: reviewGroups.value.filter((nomination) => nomination.categoryGroupName === groupName).length,
|
||||
})),
|
||||
)
|
||||
const availableSubcategoryOptions = computed(() => {
|
||||
const scopedCategories = seasonDetail.value.categories
|
||||
.filter((category) =>
|
||||
(tierCategoryFilterIds.value.length === 0 || tierCategoryFilterIds.value.includes(category.id))
|
||||
&& (!categoryFilter.value || category.groupName === categoryFilter.value),
|
||||
)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
|
||||
return [
|
||||
{
|
||||
id: null,
|
||||
label: 'Alle',
|
||||
count: reviewGroups.value.filter((nomination) =>
|
||||
(tierCategoryFilterIds.value.length === 0 || groupMatchesTierFilter(nomination, new Set(tierCategoryFilterIds.value), pendingNominationById.value))
|
||||
&& (!categoryFilter.value || nomination.categoryGroupName === categoryFilter.value),
|
||||
).length,
|
||||
},
|
||||
...scopedCategories.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.name,
|
||||
count: reviewGroups.value.filter((nomination) =>
|
||||
groupMatchesTierFilter(nomination, new Set([category.id]), pendingNominationById.value),
|
||||
).length,
|
||||
note: formatTierRangeNote(category.viewerRangeMin, category.viewerRangeMax),
|
||||
})),
|
||||
]
|
||||
})
|
||||
const tierCategoryOptions = computed(() => {
|
||||
if (!selectedNomination.value) return []
|
||||
return seasonDetail.value.categories
|
||||
.filter((category) => category.groupName === selectedNomination.value?.categoryGroupName)
|
||||
.map((category) => ({
|
||||
id: category.id,
|
||||
label: formatTierOptionLabel(category.name, category.viewerRangeMin, category.viewerRangeMax),
|
||||
}))
|
||||
})
|
||||
const selectedCandidateCollision = computed(() => {
|
||||
if (!selectedNomination.value) return null
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
if (!form) return null
|
||||
if (!form?.categoryId) 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)),
|
||||
candidate.categoryId === form.categoryId &&
|
||||
(
|
||||
(!!selectedNomination.value?.streamerIdentityId && candidate.streamerIdentityId === selectedNomination.value.streamerIdentityId)
|
||||
|| 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 = Boolean(selectedName) && 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 selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
|
||||
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||
nomination.categoryId === selectedNomination.value?.categoryId &&
|
||||
(
|
||||
(Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName) ||
|
||||
(Boolean(selectedStreamUrl) && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl)
|
||||
),
|
||||
)
|
||||
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(),
|
||||
submissions: selectedNomination.value.nominationTally,
|
||||
uniqueSubmitters: selectedNomination.value.uniqueSubmitterCount,
|
||||
platforms: selectedNomination.value.resolvedPlatform ? [selectedNomination.value.resolvedPlatform] : [],
|
||||
}
|
||||
})
|
||||
const trackingReviewNotes = computed(() =>
|
||||
seasonDetail.value.showTrackingReviewNotes
|
||||
? (seasonDetail.value.trackingReviewNotes ?? '')
|
||||
: '',
|
||||
)
|
||||
const selectedNominationHasBlockingFlag = computed(() =>
|
||||
selectedNomination.value?.trackingFlags?.some((flag) => flag.blocksApproval) ?? false,
|
||||
)
|
||||
const selectedRelatedPendingNominations = computed<AdminNominationReviewItem[]>(() => {
|
||||
if (!selectedNomination.value) return []
|
||||
const nominationIds = new Set(selectedNomination.value.nominationIds)
|
||||
return seasonDetail.value.pendingNominations
|
||||
.filter((nomination) => nominationIds.has(nomination.id))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
||||
})
|
||||
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())
|
||||
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim() && form.categoryId)
|
||||
&& !(selectedNominationHasBlockingFlag.value && selectedNomination.value.trackingReviewStatus !== 'overridden')
|
||||
})
|
||||
|
||||
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 rawCategoryGroup = Array.isArray(route.query.categoryGroup) ? route.query.categoryGroup[0] : route.query.categoryGroup
|
||||
if (typeof rawCategoryGroup === 'string' && rawCategoryGroup.trim()) {
|
||||
categoryFilter.value = rawCategoryGroup
|
||||
}
|
||||
|
||||
const nomination = seasonDetail.value.pendingNominations.find((item) => item.id === nominationId)
|
||||
const nominationId = Number(rawNominationId)
|
||||
if (!Number.isFinite(nominationId)) {
|
||||
return typeof rawCategoryGroup === 'string' && rawCategoryGroup.trim().length > 0
|
||||
}
|
||||
|
||||
const nomination = reviewGroups.value.find((item) => item.id === nominationId || item.nominationIds.includes(nominationId))
|
||||
if (!nomination) {
|
||||
return false
|
||||
}
|
||||
|
||||
reviewFilter.value = ''
|
||||
categoryFilter.value = nomination.categoryId
|
||||
categoryFilter.value = nomination.categoryGroupName
|
||||
selectedNominationId.value = nomination.id
|
||||
return true
|
||||
}
|
||||
@@ -133,13 +182,14 @@ export function useAdminReviewsManager() {
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const nomination of detail.pendingNominations) {
|
||||
const streamIdentity = resolveStreamIdentity(extractNominationStreamUrl(nomination))
|
||||
for (const nomination of detail.pendingNominationGroups ?? []) {
|
||||
const streamIdentity = resolveStreamIdentity(nomination.streamUrl ?? '')
|
||||
reviewForms[nomination.id] = {
|
||||
displayName: nomination.candidateText,
|
||||
channelSlug: streamIdentity.channelSlug,
|
||||
platform: streamIdentity.platform,
|
||||
reviewNote: nomination.reviewNote ?? '',
|
||||
displayName: nomination.displayName === 'Name im Review festlegen' ? '' : nomination.displayName,
|
||||
channelSlug: nomination.resolvedChannel ?? streamIdentity.channelSlug,
|
||||
platform: nomination.resolvedPlatform ?? streamIdentity.platform,
|
||||
categoryId: nomination.suggestedCategoryId,
|
||||
reviewNote: nomination.trackingReviewNote ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,21 +197,31 @@ export function useAdminReviewsManager() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
|
||||
if (!reviewGroups.value.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||
selectedNominationId.value = reviewGroups.value[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.nominationId,
|
||||
() => [route.query.nominationId, route.query.categoryGroup] as const,
|
||||
() => {
|
||||
focusNominationFromRoute()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
availableSubcategoryOptions,
|
||||
(tabs) => {
|
||||
if (!tabs.some((tab) => tab.id === subcategoryFilter.value)) {
|
||||
subcategoryFilter.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
filteredNominations,
|
||||
(nominations) => {
|
||||
@@ -181,7 +241,7 @@ export function useAdminReviewsManager() {
|
||||
|
||||
try {
|
||||
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
|
||||
adminMessage.value = 'Nominierungsgruppe wurde in die Kandidatenliste übernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
|
||||
} finally {
|
||||
@@ -200,7 +260,7 @@ export function useAdminReviewsManager() {
|
||||
await store.rejectAdminNomination(nominationId, selectedSeasonId.value, {
|
||||
reviewNote: reviewForms[nominationId]?.reviewNote || undefined,
|
||||
})
|
||||
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
|
||||
adminMessage.value = 'Nominierungsgruppe wurde aus der Review-Liste entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||
} finally {
|
||||
@@ -208,6 +268,45 @@ export function useAdminReviewsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenRejectedNomination(nominationId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.reopenRejectedAdminNomination(nominationId, selectedSeasonId.value, {})
|
||||
adminMessage.value = 'Verworfene Nominierung wurde wieder in die Review-Queue gelegt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht wieder geoeffnet werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTrackingReview(nominationId: number, status: 'reviewed' | 'overridden') {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
reviewSaving.value = nominationId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateNominationTrackingReview(nominationId, selectedSeasonId.value, {
|
||||
status,
|
||||
reviewNote: reviewForms[nominationId]?.reviewNote || undefined,
|
||||
})
|
||||
adminMessage.value = status === 'overridden'
|
||||
? 'Tracking-Override wurde gespeichert.'
|
||||
: 'Tracking-Review wurde als geprüft markiert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Tracking-Review konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function addStreamUrlToBlacklist(streamUrl: string) {
|
||||
const url = streamUrl.trim()
|
||||
if (!url || blacklistSaving.value) return
|
||||
@@ -226,6 +325,23 @@ export function useAdminReviewsManager() {
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedNomination() {
|
||||
if (!selectedNomination.value) return
|
||||
selectedNominationId.value = selectedNomination.value.id
|
||||
isReviewModalOpen.value = true
|
||||
}
|
||||
|
||||
function openNominationById(nominationId: number) {
|
||||
const nomination = filteredNominations.value.find((item) => item.id === nominationId)
|
||||
if (!nomination) return
|
||||
selectedNominationId.value = nomination.id
|
||||
isReviewModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeNominationModal() {
|
||||
isReviewModalOpen.value = false
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as Element
|
||||
if (
|
||||
@@ -254,6 +370,13 @@ export function useAdminReviewsManager() {
|
||||
if (prev) selectedNominationId.value = prev.id
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
if (selectedNomination.value) {
|
||||
event.preventDefault()
|
||||
openSelectedNomination()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'a': {
|
||||
if (selectedNomination.value && canApproveSelected.value && !reviewSaving.value) {
|
||||
event.preventDefault()
|
||||
@@ -306,33 +429,74 @@ export function useAdminReviewsManager() {
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
subcategoryFilter,
|
||||
selectedNominationId,
|
||||
isReviewModalOpen,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
availableSubcategoryOptions,
|
||||
tierCategoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
trackingReviewNotes,
|
||||
selectedNominationHasBlockingFlag,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
reopenRejectedNomination,
|
||||
updateTrackingReview,
|
||||
addStreamUrlToBlacklist,
|
||||
openSelectedNomination,
|
||||
openNominationById,
|
||||
closeNominationModal,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function extractNominationStreamUrl(nomination: { streamUrl?: string | null; reviewNote?: string | null }) {
|
||||
if (nomination.streamUrl?.trim()) {
|
||||
return nomination.streamUrl.trim()
|
||||
export function getTrackerStatusMeta(status: string) {
|
||||
switch (status.trim().toLowerCase()) {
|
||||
case 'resolved':
|
||||
return {
|
||||
label: 'Tracker bestätigt',
|
||||
toneClass: 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
hint: 'Viewer-Wert wurde gefunden und ein Tier automatisch vorgeschlagen.',
|
||||
}
|
||||
case 'no_data':
|
||||
return {
|
||||
label: 'Keine Viewer-Daten',
|
||||
toneClass: 'border-amber-100 bg-amber-50 text-amber-800',
|
||||
hint: 'Link wurde erkannt, aber TwitchTracker lieferte keinen belastbaren Avg-Viewer-Wert.',
|
||||
}
|
||||
case 'unsupported_platform':
|
||||
return {
|
||||
label: 'Manueller Tier-Entscheid',
|
||||
toneClass: 'border-sky-100 bg-sky-50 text-sky-800',
|
||||
hint: 'Diese Plattform wird nicht automatisch getrackt. Das Tier muss manuell festgelegt werden.',
|
||||
}
|
||||
case 'unresolved':
|
||||
return {
|
||||
label: 'Link nicht auflösbar',
|
||||
toneClass: 'border-rose-100 bg-rose-50 text-rose-800',
|
||||
hint: 'Der eingereichte Link konnte nicht sicher einem Kanal zugeordnet werden.',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
label: 'Prüfung ausstehend',
|
||||
toneClass: 'border-slate-100 bg-slate-50 text-slate-700',
|
||||
hint: 'Für diesen Fall liegt noch kein stabiler Tracker-Status vor.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const noteMatch = nomination.reviewNote?.match(/Stream-Link:\s*(https?:\/\/\S+)/i)
|
||||
return noteMatch?.[1]?.trim() ?? ''
|
||||
function extractNominationStreamUrl(nomination: { streamUrl?: string | null }) {
|
||||
return nomination.streamUrl?.trim() ?? ''
|
||||
}
|
||||
|
||||
function resolveStreamIdentity(streamUrl: string) {
|
||||
@@ -370,3 +534,36 @@ function resolveStreamIdentity(streamUrl: string) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function formatTierOptionLabel(name: string, min: number | null, max: number | null) {
|
||||
if (min === null && max === null) return name
|
||||
if (min !== null && max !== null) return `${name} (${min}-${max} Viewer)`
|
||||
if (min !== null) return `${name} (ab ${min} Viewer)`
|
||||
return `${name} (bis ${max} Viewer)`
|
||||
}
|
||||
|
||||
function formatTierRangeNote(min: number | null, max: number | null) {
|
||||
if (min === null && max === null) return 'ohne Range'
|
||||
if (min !== null && max !== null) return `${min}-${max} Viewer`
|
||||
if (min !== null) return `ab ${min} Viewer`
|
||||
return `bis ${max} Viewer`
|
||||
}
|
||||
|
||||
function groupMatchesTierFilter(
|
||||
nomination: { nominationIds: number[]; suggestedCategoryId: number | null },
|
||||
categoryIds: Set<number>,
|
||||
pendingNominationById: Map<number, AdminNominationReviewItem>,
|
||||
) {
|
||||
if (nomination.suggestedCategoryId !== null && categoryIds.has(nomination.suggestedCategoryId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const nominationId of nomination.nominationIds) {
|
||||
const pendingNomination = pendingNominationById.get(nominationId)
|
||||
if (pendingNomination?.categoryId !== null && pendingNomination?.categoryId !== undefined && categoryIds.has(pendingNomination.categoryId)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { hasShowDayPassed, normalizePhaseKey } from './adminSeasonTimeline'
|
||||
import type {
|
||||
AdminSeasonCopyOption,
|
||||
AdminSeasonCreateForm,
|
||||
AdminSeasonGatewayEntryLink,
|
||||
AdminSeasonGatewayCheckItem,
|
||||
AdminSeasonForm,
|
||||
AdminSeasonPhaseGateway,
|
||||
AdminSeasonReadinessItem,
|
||||
} from './adminSeasonTypes'
|
||||
|
||||
@@ -60,6 +63,7 @@ export function useAdminSeasonManager() {
|
||||
const seasonToDelete = ref<{ id: number; year: number; name: string; isCurrent: boolean } | null>(null)
|
||||
const deleting = ref(false)
|
||||
const completing = ref(false)
|
||||
const phaseGateway = ref<AdminSeasonPhaseGateway | null>(null)
|
||||
const seasonAuditEntries = ref<AdminAuditEntry[]>([])
|
||||
const loadingSeasonAudit = ref(false)
|
||||
let seasonAuditRequestId = 0
|
||||
@@ -88,8 +92,7 @@ export function useAdminSeasonManager() {
|
||||
const canCompleteSelectedSeason = computed(() =>
|
||||
Boolean(
|
||||
selectedSeason.value &&
|
||||
normalizePhaseKey(form.currentPhase) !== 'completed' &&
|
||||
archiveReadinessIssues.value.length === 0,
|
||||
normalizePhaseKey(form.currentPhase) !== 'completed',
|
||||
),
|
||||
)
|
||||
const canDeleteSelectedSeason = computed(() => Boolean(selectedSeason.value && !selectedSeason.value.isCurrent))
|
||||
@@ -313,6 +316,44 @@ export function useAdminSeasonManager() {
|
||||
}
|
||||
}
|
||||
|
||||
function requestPhaseGateway(phase: string) {
|
||||
const phaseKey = normalizePhaseKey(phase)
|
||||
if (phaseKey === 'voting') {
|
||||
const gateway = buildVotingGateway(seasonDetail.value)
|
||||
if (gateway.blockers.length > 0 || gateway.warnings.length > 0) {
|
||||
phaseGateway.value = gateway
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
void activatePhase(phase)
|
||||
}
|
||||
|
||||
function requestCompletionGateway() {
|
||||
const gateway = buildCompletionGateway(seasonDetail.value, form)
|
||||
if (gateway.blockers.length > 0 || gateway.warnings.length > 0) {
|
||||
phaseGateway.value = gateway
|
||||
return
|
||||
}
|
||||
|
||||
void completeSeason()
|
||||
}
|
||||
|
||||
async function confirmPhaseGateway() {
|
||||
const gateway = phaseGateway.value
|
||||
if (!gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
phaseGateway.value = null
|
||||
if (gateway.action === 'complete') {
|
||||
await completeSeason()
|
||||
return
|
||||
}
|
||||
|
||||
await activatePhase(gateway.phase)
|
||||
}
|
||||
|
||||
function openDeleteSeasonModal() {
|
||||
if (!selectedSeason.value) {
|
||||
return
|
||||
@@ -419,8 +460,12 @@ export function useAdminSeasonManager() {
|
||||
latestSeasonAuditSummary,
|
||||
latestSeasonAuditMeta,
|
||||
canCreate,
|
||||
phaseGateway,
|
||||
activatePhase,
|
||||
activatePublicSeason,
|
||||
requestPhaseGateway,
|
||||
requestCompletionGateway,
|
||||
confirmPhaseGateway,
|
||||
openCreateModal,
|
||||
saveSeason,
|
||||
completeSeason,
|
||||
@@ -447,7 +492,11 @@ function normalizeSeasonPayload<T extends { showStartsAt: string }>(payload: T):
|
||||
}
|
||||
|
||||
function resolveSeasonFacts(detail: AdminSeasonDetailResponse) {
|
||||
const categoryIdsWithCandidates = new Set(detail.candidates.map((candidate) => candidate.categoryId))
|
||||
const categoryIdsWithCandidates = new Set(
|
||||
detail.candidates
|
||||
.filter((candidate) => candidate.acceptanceStatus !== 'declined')
|
||||
.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
|
||||
@@ -465,6 +514,7 @@ function buildReadinessItems(
|
||||
currentPhase: string,
|
||||
): AdminSeasonReadinessItem[] {
|
||||
const facts = resolveSeasonFacts(detail)
|
||||
const activeCandidateCount = detail.candidates.filter((candidate) => candidate.acceptanceStatus !== 'declined').length
|
||||
const phaseKey = normalizePhaseKey(currentPhase)
|
||||
const candidateBlocking = phaseKey !== 'nomination'
|
||||
const winnerBlocking = phaseKey === 'completed'
|
||||
@@ -481,12 +531,12 @@ function buildReadinessItems(
|
||||
},
|
||||
{
|
||||
label: 'Kandidatenbasis',
|
||||
note: facts.emptyCategories === 0 && detail.candidates.length > 0
|
||||
note: facts.emptyCategories === 0 && activeCandidateCount > 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,
|
||||
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && activeCandidateCount > 0,
|
||||
blocking: candidateBlocking,
|
||||
to: '/admin/candidates',
|
||||
},
|
||||
@@ -567,6 +617,165 @@ function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
|
||||
return issues
|
||||
}
|
||||
|
||||
function buildVotingGateway(detail: AdminSeasonDetailResponse): AdminSeasonPhaseGateway {
|
||||
const blockers: AdminSeasonGatewayCheckItem[] = []
|
||||
const warnings: AdminSeasonGatewayCheckItem[] = []
|
||||
const categoryIdsWithCandidates = new Set(
|
||||
detail.candidates
|
||||
.filter((candidate) => candidate.acceptanceStatus !== 'declined')
|
||||
.map((candidate) => candidate.categoryId),
|
||||
)
|
||||
const emptyCategories = detail.categories.filter((category) => !categoryIdsWithCandidates.has(category.id))
|
||||
if (emptyCategories.length > 0) {
|
||||
blockers.push({
|
||||
label: 'Kategorien ohne voting-bereite Kandidaten',
|
||||
detail: emptyCategories.map((category) => category.name).slice(0, 4).join(', ')
|
||||
+ (emptyCategories.length > 4 ? ` +${emptyCategories.length - 4} weitere` : ''),
|
||||
status: 'block',
|
||||
to: '/admin/categories?status=empty',
|
||||
entries: emptyCategories.map((category) => createGatewayEntry(
|
||||
`${category.groupName} · ${category.name}`,
|
||||
`/admin/categories?group=${encodeURIComponent(category.groupName)}`,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
const pendingReviewGroups = detail.pendingNominationGroups ?? []
|
||||
if (pendingReviewGroups.length > 0) {
|
||||
warnings.push({
|
||||
label: 'Offene Nominierungsgruppen',
|
||||
detail: `${pendingReviewGroups.length} Review-Gruppen sollten vor dem Voting entschieden werden.`,
|
||||
status: 'warn',
|
||||
to: '/admin/nominations?review=1',
|
||||
entries: pendingReviewGroups
|
||||
.slice(0, 6)
|
||||
.map((group) => createGatewayEntry(
|
||||
`${group.categoryGroupName} · ${group.displayName} · ${group.uniqueSubmitterCount} Viewer`,
|
||||
buildReviewGatewayLink(group.id, group.categoryGroupName),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
const contactedCandidates = detail.candidates.filter((candidate) => candidate.acceptanceStatus === 'contacted')
|
||||
const openCandidates = detail.candidates.filter((candidate) => candidate.acceptanceStatus === 'open')
|
||||
if (contactedCandidates.length + openCandidates.length > 0) {
|
||||
warnings.push({
|
||||
label: 'Kandidaten ohne finale Teilnahmezusage',
|
||||
detail: `${contactedCandidates.length} angefragt, ${openCandidates.length} offen.`,
|
||||
status: 'warn',
|
||||
to: '/admin/candidates?readiness=needs_decision',
|
||||
entries: [...contactedCandidates, ...openCandidates]
|
||||
.slice(0, 6)
|
||||
.map((candidate) => createGatewayEntry(
|
||||
`${candidate.displayName} · ${candidate.acceptanceStatus === 'contacted' ? 'angefragt' : 'offen'}`,
|
||||
`/admin/candidates?readiness=needs_decision&categoryId=${candidate.categoryId}&q=${encodeURIComponent(candidate.displayName)}`,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
const pendingClips = detail.clipSubmissions.filter((clip) => clip.status === 'pending')
|
||||
if (pendingClips.length > 0) {
|
||||
warnings.push({
|
||||
label: 'Offene Clip-Prüfungen',
|
||||
detail: `${pendingClips.length} Clip-Einreichungen warten noch auf Entscheidung.`,
|
||||
status: 'warn',
|
||||
to: '/admin/clips?status=pending',
|
||||
entries: pendingClips
|
||||
.slice(0, 6)
|
||||
.map((clip) => createGatewayEntry(
|
||||
`${clip.creator} · ${clip.title}`,
|
||||
`/admin/clips?status=pending&categoryId=${clip.categoryId}&q=${encodeURIComponent(clip.creator || clip.title)}`,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'activate',
|
||||
phase: 'Community Voting',
|
||||
title: 'Check vor Community Voting',
|
||||
subtitle: 'Vor dem Voting prüfen wir Kandidatenbasis, offene Reviews und organisatorische Restpunkte.',
|
||||
blockers,
|
||||
warnings,
|
||||
confirmsLabel: 'Voting trotzdem aktivieren',
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionGateway(
|
||||
detail: AdminSeasonDetailResponse,
|
||||
form: AdminSeasonForm,
|
||||
): AdminSeasonPhaseGateway {
|
||||
const blockers: AdminSeasonGatewayCheckItem[] = []
|
||||
const warnings: AdminSeasonGatewayCheckItem[] = []
|
||||
const categoryIdsWithResults = new Set(detail.results.map((result) => result.categoryId))
|
||||
const missingWinnerCategories = detail.categories.filter((category) => !categoryIdsWithResults.has(category.id))
|
||||
if (!hasShowDayPassed(form)) {
|
||||
blockers.push({
|
||||
label: 'Award Show liegt noch nicht hinter uns',
|
||||
detail: form.showDate
|
||||
? `Geplantes Show-Datum: ${formatLocalDate(form.showDate)}`
|
||||
: 'Es fehlt ein gültiges Show-Datum.',
|
||||
status: 'block',
|
||||
to: '/admin/years',
|
||||
})
|
||||
}
|
||||
|
||||
if (missingWinnerCategories.length > 0) {
|
||||
blockers.push({
|
||||
label: 'Kategorien ohne Gewinner',
|
||||
detail: missingWinnerCategories.map((category) => category.name).slice(0, 4).join(', ')
|
||||
+ (missingWinnerCategories.length > 4 ? ` +${missingWinnerCategories.length - 4} weitere` : ''),
|
||||
status: 'block',
|
||||
to: '/admin/winners?status=open',
|
||||
entries: missingWinnerCategories.map((category) => createGatewayEntry(
|
||||
`${category.groupName} · ${category.name}`,
|
||||
`/admin/winners?status=open&categoryId=${category.id}`,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
const pendingReviewGroups = detail.pendingNominationGroups ?? []
|
||||
if (pendingReviewGroups.length > 0) {
|
||||
warnings.push({
|
||||
label: 'Noch offene Reviews',
|
||||
detail: `${pendingReviewGroups.length} Nominierungsgruppen sind noch nicht entschieden.`,
|
||||
status: 'warn',
|
||||
to: '/admin/nominations?review=1',
|
||||
entries: pendingReviewGroups
|
||||
.slice(0, 6)
|
||||
.map((group) => createGatewayEntry(
|
||||
`${group.categoryGroupName} · ${group.displayName} · ${group.uniqueSubmitterCount} Viewer`,
|
||||
buildReviewGatewayLink(group.id, group.categoryGroupName),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
const categoriesWithoutCandidates = detail.categories.filter((category) =>
|
||||
!detail.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||
)
|
||||
if (categoriesWithoutCandidates.length > 0) {
|
||||
warnings.push({
|
||||
label: 'Historische Lücken in der Kandidatenbasis',
|
||||
detail: `${categoriesWithoutCandidates.length} Kategorien hatten gar keine Kandidaten.`,
|
||||
status: 'warn',
|
||||
to: '/admin/categories?status=empty',
|
||||
entries: categoriesWithoutCandidates.map((category) => createGatewayEntry(
|
||||
`${category.groupName} · ${category.name}`,
|
||||
`/admin/categories?group=${encodeURIComponent(category.groupName)}`,
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'complete',
|
||||
phase: 'Abgeschlossen',
|
||||
title: 'Abschluss-Gateway',
|
||||
subtitle: 'Bevor das Jahr archiviert wird, prüfen wir Gewinner, Show-Zeitpunkt und offene Restarbeiten.',
|
||||
blockers,
|
||||
warnings,
|
||||
confirmsLabel: 'Jahr trotzdem abschließen',
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuditDate(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -581,3 +790,33 @@ function formatAuditDate(value: string) {
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function createGatewayEntry(label: string, to: string): AdminSeasonGatewayEntryLink {
|
||||
return { label, to }
|
||||
}
|
||||
|
||||
function buildReviewGatewayLink(nominationGroupId: number, categoryGroupName: string): string {
|
||||
return `/admin/nominations?review=1&nominationId=${nominationGroupId}&categoryGroup=${encodeURIComponent(categoryGroupName)}`
|
||||
}
|
||||
|
||||
function formatLocalDate(value: string | undefined | null) {
|
||||
if (!value) {
|
||||
return 'offen'
|
||||
}
|
||||
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
if (!year || !month || !day) {
|
||||
return value
|
||||
}
|
||||
|
||||
const date = new Date(year, month - 1, day)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ export function useAdminSettingsOverview() {
|
||||
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',
|
||||
to: categoriesWithoutCandidates.value.length > 0
|
||||
? `/admin/categories?group=${encodeURIComponent(categoriesWithoutCandidates.value[0]!.groupName)}`
|
||||
: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Risiko-Queue leer',
|
||||
@@ -143,7 +145,9 @@ export function useAdminSettingsOverview() {
|
||||
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',
|
||||
to: categoriesWithoutCandidates.value.length > 0
|
||||
? `/admin/categories?group=${encodeURIComponent(categoriesWithoutCandidates.value[0]!.groupName)}`
|
||||
: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Clip-Workflow',
|
||||
@@ -157,7 +161,7 @@ export function useAdminSettingsOverview() {
|
||||
label: 'Clip-Inbox',
|
||||
state: pendingClips.value > 0,
|
||||
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
|
||||
to: '/admin/clips',
|
||||
to: '/admin/clips?status=pending',
|
||||
},
|
||||
{
|
||||
label: 'Freitext-Reviews geklärt',
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type {
|
||||
AdminTrackingFlagRule,
|
||||
AdminTrackingMetricRule,
|
||||
AdminTrackingRulesResponse,
|
||||
AdminTrackingSource,
|
||||
} from '../../types/awards'
|
||||
|
||||
function cloneMetricRules(rules: AdminTrackingMetricRule[]) {
|
||||
return rules.map((rule) => ({
|
||||
...rule,
|
||||
autoSupportedWindowKeys: [...(rule.autoSupportedWindowKeys ?? [])],
|
||||
ignoredCategories: [...(rule.ignoredCategories ?? [])],
|
||||
}))
|
||||
}
|
||||
|
||||
function cloneFlagRules(rules: AdminTrackingFlagRule[]) {
|
||||
return rules.map((rule) => ({ ...rule }))
|
||||
}
|
||||
|
||||
function normalizeMetricRule(rule: AdminTrackingMetricRule): AdminTrackingMetricRule {
|
||||
const autoSupportedWindowKeys = Array.isArray(rule.autoSupportedWindowKeys)
|
||||
? [...new Set(rule.autoSupportedWindowKeys.map((item) => String(item).trim()).filter(Boolean))]
|
||||
: []
|
||||
|
||||
return {
|
||||
...rule,
|
||||
enabled: Boolean(rule.enabled),
|
||||
requiredForAutoClassification: Boolean(rule.requiredForAutoClassification),
|
||||
showInReview: Boolean(rule.showInReview),
|
||||
showInAdminSummary: Boolean(rule.showInAdminSummary),
|
||||
manualOverrideAllowed: Boolean(rule.manualOverrideAllowed),
|
||||
windowKey: String(rule.windowKey || '30d'),
|
||||
autoSupportedWindowKeys,
|
||||
providerFieldKey: rule.providerFieldKey?.trim() || null,
|
||||
topCount: rule.topCount == null ? null : Number(rule.topCount),
|
||||
minPrimaryCategorySharePercent: rule.minPrimaryCategorySharePercent == null ? null : Number(rule.minPrimaryCategorySharePercent),
|
||||
minPrimaryCategoryHours: rule.minPrimaryCategoryHours == null ? null : Number(rule.minPrimaryCategoryHours),
|
||||
maxDistinctCategoriesBeforeFlag: rule.maxDistinctCategoriesBeforeFlag == null ? null : Number(rule.maxDistinctCategoriesBeforeFlag),
|
||||
ignoredCategories: Array.isArray(rule.ignoredCategories)
|
||||
? [...new Set(rule.ignoredCategories.map((item) => String(item).trim()).filter(Boolean))]
|
||||
: [],
|
||||
matchAwardCategoryAgainstTopCategories: Boolean(rule.matchAwardCategoryAgainstTopCategories),
|
||||
flagIfAwardCategoryNotInTopX: Boolean(rule.flagIfAwardCategoryNotInTopX),
|
||||
flagIfCategorySpreadTooWide: Boolean(rule.flagIfCategorySpreadTooWide),
|
||||
flagIfNoCategoryContextAvailable: Boolean(rule.flagIfNoCategoryContextAvailable),
|
||||
minValue: rule.minValue == null ? null : Number(rule.minValue),
|
||||
maxValue: rule.maxValue == null ? null : Number(rule.maxValue),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFlagRule(rule: AdminTrackingFlagRule): AdminTrackingFlagRule {
|
||||
return {
|
||||
...rule,
|
||||
enabled: Boolean(rule.enabled),
|
||||
autoTriggerEnabled: Boolean(rule.autoTriggerEnabled),
|
||||
requiresManualReview: Boolean(rule.requiresManualReview),
|
||||
blocksApproval: Boolean(rule.blocksApproval),
|
||||
adminNoteRequiredOnOverride: Boolean(rule.adminNoteRequiredOnOverride),
|
||||
severity: rule.severity === 'high' || rule.severity === 'low' ? rule.severity : 'medium',
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyTrackingRules(): AdminTrackingRulesResponse {
|
||||
return {
|
||||
source: {
|
||||
providerKey: 'twitchtracker',
|
||||
providerLabel: 'TwitchTracker Basic API',
|
||||
baseUrl: 'https://twitchtracker.com/api',
|
||||
notesSummary: '',
|
||||
showManualReviewNotesInReview: true,
|
||||
},
|
||||
importantMetrics: [],
|
||||
optionalMetrics: [],
|
||||
flags: [],
|
||||
manualReviewNotes: '',
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceSnapshot(source: AdminTrackingSource) {
|
||||
return JSON.stringify(source)
|
||||
}
|
||||
|
||||
function parseSourceSnapshot(snapshot: string): AdminTrackingSource {
|
||||
if (!snapshot) {
|
||||
return createEmptyTrackingRules().source
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(snapshot) as AdminTrackingSource
|
||||
return {
|
||||
providerKey: parsed.providerKey || 'twitchtracker',
|
||||
providerLabel: parsed.providerLabel || 'TwitchTracker Basic API',
|
||||
baseUrl: parsed.baseUrl || 'https://twitchtracker.com/api',
|
||||
notesSummary: parsed.notesSummary || '',
|
||||
showManualReviewNotesInReview: parsed.showManualReviewNotesInReview !== false,
|
||||
}
|
||||
} catch {
|
||||
return createEmptyTrackingRules().source
|
||||
}
|
||||
}
|
||||
|
||||
function createRulesSnapshot(
|
||||
importantMetrics: AdminTrackingMetricRule[],
|
||||
optionalMetrics: AdminTrackingMetricRule[],
|
||||
flags: AdminTrackingFlagRule[],
|
||||
) {
|
||||
return JSON.stringify({
|
||||
importantMetrics: importantMetrics.map(normalizeMetricRule),
|
||||
optionalMetrics: optionalMetrics.map(normalizeMetricRule),
|
||||
flags: flags.map(normalizeFlagRule),
|
||||
})
|
||||
}
|
||||
|
||||
export function useAdminTrackingRules() {
|
||||
const store = useAwardsStore()
|
||||
const trackingLoading = ref(false)
|
||||
const rulesSaving = ref(false)
|
||||
const sourceSaving = ref(false)
|
||||
const notesSaving = ref(false)
|
||||
const trackingError = ref('')
|
||||
const trackingSuccess = ref('')
|
||||
const trackingSource = ref<AdminTrackingSource>(createEmptyTrackingRules().source)
|
||||
const importantMetrics = ref<AdminTrackingMetricRule[]>([])
|
||||
const optionalMetrics = ref<AdminTrackingMetricRule[]>([])
|
||||
const trackingFlags = ref<AdminTrackingFlagRule[]>([])
|
||||
const manualReviewNotes = ref('')
|
||||
const sourceSnapshot = ref('')
|
||||
const rulesSnapshot = ref('')
|
||||
const notesSnapshot = ref('')
|
||||
|
||||
const trackingSummary = computed(() => ({
|
||||
importantActive: importantMetrics.value.filter((item) => item.enabled).length,
|
||||
optionalActive: optionalMetrics.value.filter((item) => item.enabled).length,
|
||||
flagsActive: trackingFlags.value.filter((item) => item.enabled).length,
|
||||
manualReviewFlags: trackingFlags.value.filter((item) => item.enabled && item.requiresManualReview).length,
|
||||
}))
|
||||
|
||||
const hasUnsavedTrackingSourceChanges = computed(() => createSourceSnapshot(trackingSource.value) !== sourceSnapshot.value)
|
||||
const hasUnsavedTrackingRuleChanges = computed(() =>
|
||||
createRulesSnapshot(importantMetrics.value, optionalMetrics.value, trackingFlags.value) !== rulesSnapshot.value,
|
||||
)
|
||||
const hasUnsavedTrackingNotesChanges = computed(() => manualReviewNotes.value !== notesSnapshot.value)
|
||||
|
||||
function applyResponse(response: AdminTrackingRulesResponse) {
|
||||
trackingSource.value = {
|
||||
providerKey: response.source?.providerKey || 'twitchtracker',
|
||||
providerLabel: response.source?.providerLabel || 'TwitchTracker Basic API',
|
||||
baseUrl: response.source?.baseUrl || 'https://twitchtracker.com/api',
|
||||
notesSummary: response.source?.notesSummary || '',
|
||||
showManualReviewNotesInReview: response.source?.showManualReviewNotesInReview !== false,
|
||||
}
|
||||
importantMetrics.value = cloneMetricRules(response.importantMetrics)
|
||||
optionalMetrics.value = cloneMetricRules(response.optionalMetrics)
|
||||
trackingFlags.value = cloneFlagRules(response.flags)
|
||||
manualReviewNotes.value = response.manualReviewNotes ?? ''
|
||||
sourceSnapshot.value = createSourceSnapshot(trackingSource.value)
|
||||
rulesSnapshot.value = createRulesSnapshot(importantMetrics.value, optionalMetrics.value, trackingFlags.value)
|
||||
notesSnapshot.value = manualReviewNotes.value
|
||||
}
|
||||
|
||||
async function loadTrackingRules() {
|
||||
trackingLoading.value = true
|
||||
trackingError.value = ''
|
||||
trackingSuccess.value = ''
|
||||
try {
|
||||
applyResponse(await store.loadAdminTrackingRules())
|
||||
} catch (error) {
|
||||
trackingError.value = error instanceof Error ? error.message : 'Tracking Rules konnten nicht geladen werden.'
|
||||
applyResponse(createEmptyTrackingRules())
|
||||
} finally {
|
||||
trackingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateImportantMetric(ruleKey: string, patch: Partial<AdminTrackingMetricRule>) {
|
||||
importantMetrics.value = importantMetrics.value.map((rule) =>
|
||||
rule.key === ruleKey ? normalizeMetricRule({ ...rule, ...patch }) : rule,
|
||||
)
|
||||
}
|
||||
|
||||
function updateOptionalMetric(ruleKey: string, patch: Partial<AdminTrackingMetricRule>) {
|
||||
optionalMetrics.value = optionalMetrics.value.map((rule) =>
|
||||
rule.key === ruleKey ? normalizeMetricRule({ ...rule, ...patch }) : rule,
|
||||
)
|
||||
}
|
||||
|
||||
function updateTrackingFlag(ruleKey: string, patch: Partial<AdminTrackingFlagRule>) {
|
||||
trackingFlags.value = trackingFlags.value.map((rule) =>
|
||||
rule.key === ruleKey ? normalizeFlagRule({ ...rule, ...patch }) : rule,
|
||||
)
|
||||
}
|
||||
|
||||
async function saveTrackingSource() {
|
||||
sourceSaving.value = true
|
||||
trackingError.value = ''
|
||||
trackingSuccess.value = ''
|
||||
try {
|
||||
const response = await store.updateAdminTrackingSource({
|
||||
source: { ...trackingSource.value },
|
||||
})
|
||||
applyResponse(response)
|
||||
trackingSuccess.value = 'Tracking Source wurde gespeichert.'
|
||||
} catch (error) {
|
||||
trackingError.value = error instanceof Error ? error.message : 'Tracking Source konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
sourceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTrackingNotes() {
|
||||
notesSaving.value = true
|
||||
trackingError.value = ''
|
||||
trackingSuccess.value = ''
|
||||
try {
|
||||
const response = await store.updateAdminTrackingReviewNotes({
|
||||
manualReviewNotes: manualReviewNotes.value,
|
||||
showManualReviewNotesInReview: trackingSource.value.showManualReviewNotesInReview,
|
||||
})
|
||||
applyResponse(response)
|
||||
trackingSuccess.value = 'Manual Review Notes wurden gespeichert.'
|
||||
} catch (error) {
|
||||
trackingError.value = error instanceof Error ? error.message : 'Manual Review Notes konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
notesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTrackingRules() {
|
||||
rulesSaving.value = true
|
||||
trackingError.value = ''
|
||||
trackingSuccess.value = ''
|
||||
try {
|
||||
const response = await store.updateAdminTrackingRules({
|
||||
source: parseSourceSnapshot(sourceSnapshot.value),
|
||||
importantMetrics: importantMetrics.value.map(normalizeMetricRule),
|
||||
optionalMetrics: optionalMetrics.value.map(normalizeMetricRule),
|
||||
flags: trackingFlags.value.map(normalizeFlagRule),
|
||||
manualReviewNotes: notesSnapshot.value,
|
||||
})
|
||||
applyResponse(response)
|
||||
trackingSuccess.value = 'Tracking Rules wurden gespeichert.'
|
||||
} catch (error) {
|
||||
trackingError.value = error instanceof Error ? error.message : 'Tracking Rules konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
rulesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTrackingRules)
|
||||
|
||||
return {
|
||||
trackingLoading,
|
||||
rulesSaving,
|
||||
sourceSaving,
|
||||
notesSaving,
|
||||
trackingError,
|
||||
trackingSuccess,
|
||||
trackingSource,
|
||||
importantMetrics,
|
||||
optionalMetrics,
|
||||
trackingFlags,
|
||||
manualReviewNotes,
|
||||
trackingSummary,
|
||||
hasUnsavedTrackingSourceChanges,
|
||||
hasUnsavedTrackingRuleChanges,
|
||||
hasUnsavedTrackingNotesChanges,
|
||||
loadTrackingRules,
|
||||
updateImportantMetric,
|
||||
updateOptionalMetric,
|
||||
updateTrackingFlag,
|
||||
saveTrackingSource,
|
||||
saveTrackingNotes,
|
||||
saveTrackingRules,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { buildClipEmbed } from '../../lib/clipEmbeds'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
||||
|
||||
@@ -13,30 +15,81 @@ type WinnerRuleNotice = {
|
||||
message: string
|
||||
}
|
||||
|
||||
type WinnerIdentitySummary = {
|
||||
appearances: number
|
||||
acceptedAppearances: number
|
||||
winnerPlacements: number
|
||||
nominationTally: number
|
||||
hasClip: boolean
|
||||
}
|
||||
|
||||
type WinnerClipPreview = {
|
||||
clipUrl: string | null
|
||||
clipTitle: string
|
||||
clipPlatform: string
|
||||
clipEmbedUrl: string | null
|
||||
clipEmbedTitle: string
|
||||
clipEmbedStatus: string
|
||||
}
|
||||
|
||||
export function useAdminWinnersManager() {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
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 categoryFilter = ref<number | null>(null)
|
||||
const winnerSelections = reactive<Record<number, string>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const workflowRules = computed(() => store.adminWorkflowRules.rules)
|
||||
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
||||
const selectedCandidateMap = computed(() => new Map(seasonDetail.value.candidates.map((candidate) => [candidate.id, candidate])))
|
||||
const workflowRuleSummary = computed(() => {
|
||||
const maxWinnerRule = findWorkflowRule(maxWinnerPlacementsRuleKey)
|
||||
const clipRule = findWorkflowRule(winnerRequiresClipRuleKey)
|
||||
return {
|
||||
maxWinnerPlacements: maxWinnerRule,
|
||||
winnerRequiresClip: clipRule,
|
||||
}
|
||||
})
|
||||
|
||||
function findWorkflowRule(ruleKey: string): AdminWorkflowRule | null {
|
||||
return workflowRules.value.find((rule) => rule.key === ruleKey) ?? null
|
||||
}
|
||||
|
||||
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'>) {
|
||||
function candidateIdentityKey(candidate: Pick<AdminCandidateItem, 'displayName' | 'channelSlug'> & { streamerIdentityId?: number | null }) {
|
||||
if ('streamerIdentityId' in candidate && typeof candidate.streamerIdentityId === 'number') {
|
||||
return `identity:${candidate.streamerIdentityId}`
|
||||
}
|
||||
const channel = candidate.channelSlug.trim().replace(/^@+/, '').toLowerCase()
|
||||
return channel ? `slug:${channel}` : `name:${candidate.displayName.trim().toLowerCase()}`
|
||||
}
|
||||
|
||||
function selectedWinnerIdentitySummary(categoryId: number): WinnerIdentitySummary | null {
|
||||
const candidateId = Number(winnerSelections[categoryId])
|
||||
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
|
||||
if (!selectedCandidate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sameIdentityCandidates = seasonDetail.value.candidates.filter((candidate) =>
|
||||
candidateIdentityKey(candidate) === candidateIdentityKey(selectedCandidate),
|
||||
)
|
||||
const winnerCandidateIds = new Set(seasonDetail.value.results.map((result) => result.candidateId))
|
||||
|
||||
return {
|
||||
appearances: sameIdentityCandidates.filter((candidate) => candidate.acceptanceStatus !== 'declined').length,
|
||||
acceptedAppearances: sameIdentityCandidates.filter((candidate) => candidate.acceptanceStatus === 'accepted').length,
|
||||
winnerPlacements: sameIdentityCandidates.filter((candidate) => winnerCandidateIds.has(candidate.id)).length,
|
||||
nominationTally: Math.max(...sameIdentityCandidates.map((candidate) => candidate.nominationTally ?? 0), 0),
|
||||
hasClip: Boolean(selectedCandidate.clipCompilationUrl?.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
function winnerRuleNoticeFor(categoryId: number): WinnerRuleNotice | null {
|
||||
const candidateId = Number(winnerSelections[categoryId])
|
||||
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
|
||||
@@ -58,6 +111,7 @@ export function useAdminWinnersManager() {
|
||||
const existingWinnerCount = seasonDetail.value.results.filter((result) => {
|
||||
if (result.categoryId === categoryId) return false
|
||||
return candidateIdentityKey({
|
||||
streamerIdentityId: result.streamerIdentityId,
|
||||
displayName: result.candidateDisplayName,
|
||||
channelSlug: result.candidateChannelSlug,
|
||||
}) === identityKey
|
||||
@@ -72,6 +126,30 @@ export function useAdminWinnersManager() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectedWinnerClipPreview(categoryId: number): WinnerClipPreview | null {
|
||||
const candidateId = Number(winnerSelections[categoryId])
|
||||
const selectedCandidate = selectedCandidateMap.value.get(candidateId)
|
||||
if (!selectedCandidate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const clipUrl = selectedCandidate.clipCompilationUrl?.trim() || null
|
||||
const clipTitle = selectedCandidate.clipCompilationTitle?.trim() || 'Clip-Link ansehen'
|
||||
const clipPlatform = selectedCandidate.clipCompilationPlatform?.trim() || selectedCandidate.platform
|
||||
const clipEmbed = clipUrl && selectedCandidate.clipEmbedStatus !== 'link_only' && selectedCandidate.clipEmbedStatus !== 'blocked'
|
||||
? buildClipEmbed(clipUrl)
|
||||
: null
|
||||
|
||||
return {
|
||||
clipUrl,
|
||||
clipTitle,
|
||||
clipPlatform,
|
||||
clipEmbedUrl: clipEmbed?.src ?? null,
|
||||
clipEmbedTitle: clipEmbed?.title ?? clipTitle,
|
||||
clipEmbedStatus: selectedCandidate.clipEmbedStatus || 'unchecked',
|
||||
}
|
||||
}
|
||||
|
||||
const resultRows = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => {
|
||||
@@ -79,7 +157,10 @@ export function useAdminWinnersManager() {
|
||||
.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 openReviews = seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||
nomination.categoryId === category.id
|
||||
|| nomination.suggestedCategoryId === category.id,
|
||||
).length
|
||||
const approvedClips = seasonDetail.value.clipSubmissions.filter((clip) => clip.categoryId === category.id && clip.status === 'approved').length
|
||||
|
||||
return {
|
||||
@@ -121,6 +202,7 @@ export function useAdminWinnersManager() {
|
||||
|| (statusFilter.value === 'empty' && row.isEmpty)
|
||||
|
||||
if (!matchesStatus) return false
|
||||
if (categoryFilter.value && row.category.id !== categoryFilter.value) return false
|
||||
if (!normalizedQuery) return true
|
||||
|
||||
return [
|
||||
@@ -163,6 +245,18 @@ export function useAdminWinnersManager() {
|
||||
},
|
||||
])
|
||||
|
||||
function applyRouteFilters() {
|
||||
const rawQuery = Array.isArray(route.query.q) ? route.query.q[0] : route.query.q
|
||||
const rawStatus = Array.isArray(route.query.status) ? route.query.status[0] : route.query.status
|
||||
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
|
||||
|
||||
query.value = typeof rawQuery === 'string' ? rawQuery : ''
|
||||
const nextStatus = typeof rawStatus === 'string' ? rawStatus : allFilter
|
||||
statusFilter.value = statusFilters.value.some((option) => option.value === nextStatus) ? nextStatus : allFilter
|
||||
const parsedCategoryId = Number(rawCategoryId)
|
||||
categoryFilter.value = Number.isFinite(parsedCategoryId) && parsedCategoryId > 0 ? parsedCategoryId : null
|
||||
}
|
||||
|
||||
watch(resultRows, (rows) => {
|
||||
const knownCategoryIds = new Set(rows.map((row) => row.category.id))
|
||||
for (const key of Object.keys(winnerSelections)) {
|
||||
@@ -176,6 +270,14 @@ export function useAdminWinnersManager() {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => [route.query.q, route.query.status, route.query.categoryId] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function saveWinner(categoryId: number) {
|
||||
const candidateId = Number(winnerSelections[categoryId])
|
||||
if (!candidateId || !seasonDetail.value.id) return
|
||||
@@ -228,6 +330,9 @@ export function useAdminWinnersManager() {
|
||||
winnerSelections,
|
||||
clearWinner,
|
||||
saveWinner,
|
||||
workflowRuleSummary,
|
||||
selectedWinnerIdentitySummary,
|
||||
selectedWinnerClipPreview,
|
||||
winnerRuleNoticeFor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminWorkflowRule } from '../../types/awards'
|
||||
@@ -18,6 +18,7 @@ function normalizeRule(rule: AdminWorkflowRule): AdminWorkflowRule {
|
||||
|
||||
export function useAdminWorkflowRules() {
|
||||
const store = useAwardsStore()
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const workflowLoading = ref(false)
|
||||
const workflowSaving = ref(false)
|
||||
const workflowError = ref('')
|
||||
@@ -81,6 +82,11 @@ export function useAdminWorkflowRules() {
|
||||
}
|
||||
|
||||
onMounted(loadWorkflowRules)
|
||||
watch(selectedSeasonId, (next, previous) => {
|
||||
if (next && next !== previous) {
|
||||
void loadWorkflowRules()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
hasUnsavedWorkflowRuleChanges,
|
||||
|
||||
@@ -60,14 +60,14 @@ function initialsFor(value: string) {
|
||||
<article
|
||||
v-for="winner in props.selectedArchive.winners"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
||||
style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:15px;padding:16px 18px;border-radius:20px;background:rgba(255,255,255,.74);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
|
||||
style="display:grid;grid-template-columns:84px minmax(0,1fr);gap:16px;padding:18px;border-radius:24px;background:rgba(255,255,255,.78);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);align-items:start;"
|
||||
>
|
||||
<div style="width:76px;height:76px;border-radius:22px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:24px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
|
||||
<div style="width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:26px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
|
||||
{{ initialsFor(winner.name) }}
|
||||
</div>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-size:10px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#d9942a;margin-bottom:7px;">{{ winner.category }}</div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:18px;line-height:1.25;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ winner.name }}</div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:20px;line-height:1.2;color:#3f3556;">{{ winner.name }}</div>
|
||||
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:9px;margin-top:12px;">
|
||||
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
|
||||
@@ -94,6 +94,26 @@ function initialsFor(value: string) {
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
style="width:100%;aspect-ratio:16/9;border:0;border-radius:14px;background:#1b0d2d;margin-top:12px;"
|
||||
/>
|
||||
<div
|
||||
v-else-if="winner.clipUrl"
|
||||
style="display:flex;align-items:center;justify-content:center;aspect-ratio:16/9;border-radius:14px;background:rgba(255,255,255,.88);border:1px solid rgba(139,108,219,.12);margin-top:12px;padding:14px;text-align:center;"
|
||||
>
|
||||
<a
|
||||
:href="winner.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
style="color:#7c5bb4;text-decoration:none;font-family:'Outfit',sans-serif;font-size:13px;font-weight:700;"
|
||||
>
|
||||
Clip extern ansehen
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
style="display:flex;align-items:center;justify-content:center;aspect-ratio:16/9;border-radius:14px;background:rgba(255,248,232,.92);border:1px dashed rgba(231,177,62,.42);margin-top:12px;padding:14px;text-align:center;color:#b7791f;font-family:'Outfit',sans-serif;font-size:13px;font-weight:700;"
|
||||
>
|
||||
Fuer diesen Gewinner ist kein Clip hinterlegt.
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -2,28 +2,33 @@
|
||||
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
||||
<div style="text-align:center;margin-bottom:52px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#ff5fa2);margin-bottom:12px;">✦ Die Awards</div>
|
||||
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Newcomer bis VTuber des Jahres — für jede Art von Magie gibt es einen Stern zu gewinnen.</p>
|
||||
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ landingCategories.length }} Hauptkategorien · 1 Sternenhimmel</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Gaming bis Creative - jede Hauptkategorie bringt ihre eigenen Viewer-Tiers und Favoriten mit.</p>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
|
||||
<div v-for="category in displayCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
|
||||
<div v-for="category in landingCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
|
||||
<div style="display:inline-flex;align-items:center;justify-content:center;width:50px;height:50px;border-radius:15px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:24px;margin-bottom:16px;box-shadow:0 8px 20px var(--glow,rgba(255,95,162,.4));">{{ category.icon }}</div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;margin:0 0 6px;">{{ category.name }}</h3>
|
||||
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.candidates.length }} Kandidat:innen aktuell in dieser Award-Kategorie.</p>
|
||||
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.subcategoryCount }} Viewer-Tiers · {{ category.candidateCount }} Kandidat:innen aktuell.</p>
|
||||
<p v-if="category.description" style="font-size:13px;color:var(--muted,#c9b8da);margin:12px 0 0;line-height:1.5;">{{ category.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type DisplayCategory = {
|
||||
type LandingCategory = {
|
||||
id: string
|
||||
groupName: string
|
||||
name: string
|
||||
icon: string
|
||||
candidates: unknown[]
|
||||
description: string
|
||||
maxNomineesPerUser: number
|
||||
candidateCount: number
|
||||
subcategoryCount: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
displayCategories: DisplayCategory[]
|
||||
landingCategories: LandingCategory[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface HomeCategoryProgressRailItem {
|
||||
id: string | number
|
||||
name: string
|
||||
groupName?: string
|
||||
icon: string
|
||||
status: string
|
||||
tone: 'empty' | 'active' | 'done' | 'error'
|
||||
@@ -13,25 +16,42 @@ const props = defineProps<{
|
||||
label: string
|
||||
onSelect: (id: string | number) => void
|
||||
}>()
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
const groups: Array<{ name: string; items: HomeCategoryProgressRailItem[] }> = []
|
||||
for (const item of props.items) {
|
||||
const groupName = item.groupName?.trim() || 'Weitere Kategorien'
|
||||
const current = groups.at(-1)
|
||||
if (current?.name === groupName) {
|
||||
current.items.push(item)
|
||||
} else {
|
||||
groups.push({ name: groupName, items: [item] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="home-wizard-rail">
|
||||
<div class="home-wizard-rail__label">{{ props.label }}</div>
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="home-wizard-rail__button"
|
||||
:class="[
|
||||
`home-wizard-rail__button--${item.tone}`,
|
||||
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
|
||||
]"
|
||||
@click="props.onSelect(item.id)"
|
||||
>
|
||||
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
|
||||
<span class="home-wizard-rail__name">{{ item.name }}</span>
|
||||
<span class="home-wizard-rail__status">{{ item.status }}</span>
|
||||
</button>
|
||||
<section v-for="group in groupedItems" :key="group.name" class="home-wizard-rail__group">
|
||||
<div class="home-wizard-rail__group-label">{{ group.name }}</div>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="home-wizard-rail__button"
|
||||
:class="[
|
||||
`home-wizard-rail__button--${item.tone}`,
|
||||
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
|
||||
]"
|
||||
@click="props.onSelect(item.id)"
|
||||
>
|
||||
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
|
||||
<span class="home-wizard-rail__name">{{ item.name }}</span>
|
||||
<span class="home-wizard-rail__status">{{ item.status }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,59 +1,408 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { CheckCircle2, Handshake, Mic2, Send } from '@lucide/vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Handshake, Mic2, Send, X } from '@lucide/vue'
|
||||
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { useAdminToast } from '../../composables/useAdminToast'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { ShowactFormField } from '../../types/awards/showactForm'
|
||||
import { DEFAULT_SHOWACT_SCHEMA } from '../../types/awards/showactForm'
|
||||
import type { CreateShowactApplicationPayload } from '../../types/awards'
|
||||
import HomeStarField from './HomeStarField.vue'
|
||||
|
||||
const props = defineProps<{ modalOpen?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modalOpen': [boolean] }>()
|
||||
|
||||
const { showAdminToast } = useAdminToast()
|
||||
const store = useAwardsStore()
|
||||
const submitting = ref(false)
|
||||
const submitMessage = ref('')
|
||||
const submitError = ref('')
|
||||
const form = reactive({
|
||||
artistName: '',
|
||||
contactEmail: '',
|
||||
contactDiscord: '',
|
||||
platformUrl: '',
|
||||
performanceType: '',
|
||||
description: '',
|
||||
technicalNotes: '',
|
||||
referenceUrl: '',
|
||||
})
|
||||
const localModalOpen = ref(false)
|
||||
const showactInfoModalOpen = ref(false)
|
||||
|
||||
const fieldValues = reactive<Record<string, string>>({})
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
|
||||
const featureFlags = computed(() => store.overview.featureFlags)
|
||||
const sponsors = computed(() =>
|
||||
featureFlags.value.sponsorsVisible ? store.publicSponsors.items.filter((item) => item.isVisible) : [],
|
||||
)
|
||||
const showSection = computed(() => sponsors.value.length > 0 || featureFlags.value.showactApplicationsEnabled)
|
||||
const showactFooterLink = computed(() =>
|
||||
(store.overview.siteContent.footerLinks ?? []).find((link) => link.key === 'showacts') ?? null,
|
||||
)
|
||||
const showactInfoHtml = computed(() => privacyContentToHtml(showactFooterLink.value?.content || ''))
|
||||
const showactHasInfo = computed(() =>
|
||||
Boolean(
|
||||
showactFooterLink.value?.content?.trim()
|
||||
|| (showactFooterLink.value?.url && isSafePublicUrl(showactFooterLink.value.url))
|
||||
|| featureFlags.value.showactApplicationDisabledMessage?.trim()
|
||||
|| featureFlags.value.showactApplicationStartsAt
|
||||
|| featureFlags.value.showactApplicationEndsAt,
|
||||
),
|
||||
)
|
||||
const showactVisible = computed(() => featureFlags.value.showactApplicationsEnabled || showactHasInfo.value)
|
||||
const showSection = computed(() => sponsors.value.length > 0 || showactVisible.value)
|
||||
const showactDeadlineText = computed(() => {
|
||||
const deadline = featureFlags.value.showactApplicationEndsAt
|
||||
if (!deadline) return ''
|
||||
|
||||
const parsed = new Date(`${deadline}T00:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return `Bewerbung bis ${deadline}`
|
||||
}
|
||||
|
||||
return `Bewerbung bis ${parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`
|
||||
})
|
||||
const showactStatusText = computed(() => {
|
||||
if (featureFlags.value.showactApplicationsEnabled) {
|
||||
return 'Bewerbungen sind aktuell offen.'
|
||||
}
|
||||
|
||||
return featureFlags.value.showactApplicationDisabledMessage?.trim()
|
||||
|| 'Showact-Bewerbungen sind aktuell geschlossen.'
|
||||
})
|
||||
const showactPrimaryLabel = computed(() =>
|
||||
featureFlags.value.showactApplicationsEnabled ? 'Jetzt bewerben' : 'Mehr erfahren',
|
||||
)
|
||||
const showactPrimaryHref = computed(() =>
|
||||
!featureFlags.value.showactApplicationsEnabled && isSafePublicUrl(showactFooterLink.value?.url)
|
||||
? showactFooterLink.value?.url ?? ''
|
||||
: '',
|
||||
)
|
||||
const showactPrimaryDisabled = computed(() =>
|
||||
!featureFlags.value.showactApplicationsEnabled
|
||||
&& !showactInfoHtml.value
|
||||
&& !showactPrimaryHref.value,
|
||||
)
|
||||
const showactPrimaryButtonStyle = computed(() =>
|
||||
showactPrimaryDisabled.value
|
||||
? "display:inline-flex;align-items:center;gap:9px;height:44px;padding:0 20px;border-radius:14px;background:#c9b8da;border:none;color:#fff;font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;cursor:not-allowed;box-shadow:none;transition:transform .15s,box-shadow .15s;flex-shrink:0;opacity:.86;"
|
||||
: "display:inline-flex;align-items:center;gap:9px;height:44px;padding:0 20px;border-radius:14px;background:#8b6cdb;border:none;color:#fff;font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;cursor:pointer;box-shadow:0 4px 14px rgba(124,86,196,.25);transition:transform .15s,box-shadow .15s;flex-shrink:0;",
|
||||
)
|
||||
|
||||
const showactSchema = computed((): ShowactFormField[] => {
|
||||
const json = featureFlags.value.showactFormSchemaJson ?? '[]'
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed as ShowactFormField[]
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_SHOWACT_SCHEMA
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.artistName = ''
|
||||
form.contactEmail = ''
|
||||
form.contactDiscord = ''
|
||||
form.platformUrl = ''
|
||||
form.performanceType = ''
|
||||
form.description = ''
|
||||
form.technicalNotes = ''
|
||||
form.referenceUrl = ''
|
||||
for (const key of Object.keys(fieldValues)) {
|
||||
delete fieldValues[key]
|
||||
}
|
||||
for (const key of Object.keys(fieldErrors)) {
|
||||
delete fieldErrors[key]
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
localModalOpen.value = false
|
||||
emit('update:modalOpen', false)
|
||||
}
|
||||
|
||||
function openShowactPrimaryAction() {
|
||||
if (featureFlags.value.showactApplicationsEnabled) {
|
||||
localModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (showactInfoHtml.value) {
|
||||
showactInfoModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackUrl = showactPrimaryHref.value
|
||||
if (fallbackUrl && typeof window !== 'undefined') {
|
||||
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
function closeShowactInfoModal() {
|
||||
showactInfoModalOpen.value = false
|
||||
emit('update:modalOpen', false)
|
||||
}
|
||||
|
||||
// For multiselect: values are stored as comma-separated strings
|
||||
function getMultiselectValues(fieldId: string): string[] {
|
||||
const raw = fieldValues[fieldId] ?? ''
|
||||
return raw ? raw.split(',').map((v) => v.trim()).filter(Boolean) : []
|
||||
}
|
||||
|
||||
function otherFieldId(fieldId: string) {
|
||||
return `${fieldId}__other`
|
||||
}
|
||||
|
||||
function getOtherValue(fieldId: string) {
|
||||
return (fieldValues[otherFieldId(fieldId)] ?? '').trim()
|
||||
}
|
||||
|
||||
function setMultiselectValue(fieldId: string, option: string, checked: boolean) {
|
||||
const current = getMultiselectValues(fieldId)
|
||||
if (checked) {
|
||||
if (!current.includes(option)) current.push(option)
|
||||
} else {
|
||||
const idx = current.indexOf(option)
|
||||
if (idx !== -1) current.splice(idx, 1)
|
||||
}
|
||||
fieldValues[fieldId] = current.join(', ')
|
||||
delete fieldErrors[fieldId]
|
||||
}
|
||||
|
||||
function setCheckboxValue(field: ShowactFormField, checked: boolean) {
|
||||
const value = field.options[0] || 'Ja'
|
||||
fieldValues[field.id] = checked ? value : ''
|
||||
delete fieldErrors[field.id]
|
||||
}
|
||||
|
||||
function isCheckboxChecked(field: ShowactFormField) {
|
||||
return Boolean((fieldValues[field.id] ?? '').trim())
|
||||
}
|
||||
|
||||
function updateOtherValue(field: ShowactFormField, value: string) {
|
||||
fieldValues[otherFieldId(field.id)] = value
|
||||
delete fieldErrors[field.id]
|
||||
}
|
||||
|
||||
function validateField(field: ShowactFormField): string {
|
||||
const value = (fieldValues[field.id] ?? '').trim()
|
||||
const otherValue = field.allowOther ? getOtherValue(field.id) : ''
|
||||
|
||||
if (field.required && !value && !otherValue) {
|
||||
if (field.type === 'checkbox') {
|
||||
return `Bitte bestaetige „${field.label}".`
|
||||
}
|
||||
|
||||
if (field.type === 'radio' || field.type === 'select' || field.type === 'multiselect') {
|
||||
return `Bitte waehle bei „${field.label}" eine Antwort aus.`
|
||||
}
|
||||
|
||||
return `Bitte fuell das Feld „${field.label}" aus.`
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (field.type === 'email') {
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailPattern.test(value)) {
|
||||
return `Bitte gib bei „${field.label}" eine gueltige E-Mail-Adresse ein.`
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'url') {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return `Bitte gib bei „${field.label}" einen gueltigen Link mit http oder https ein.`
|
||||
}
|
||||
} catch {
|
||||
return `Bitte gib bei „${field.label}" einen gueltigen Link ein.`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function updateFieldError(field: ShowactFormField) {
|
||||
const message = validateField(field)
|
||||
if (message) {
|
||||
fieldErrors[field.id] = message
|
||||
return
|
||||
}
|
||||
|
||||
delete fieldErrors[field.id]
|
||||
}
|
||||
|
||||
function resolveFieldValue(field?: ShowactFormField | null, source: Record<string, string> = fieldValues) {
|
||||
if (!field) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (source[field.id] ?? '').trim()
|
||||
}
|
||||
|
||||
function findFieldByMatcher(matcher: (field: ShowactFormField) => boolean) {
|
||||
return showactSchema.value.find(matcher) ?? null
|
||||
}
|
||||
|
||||
function fieldHaystack(field: ShowactFormField) {
|
||||
return `${field.id} ${field.label}`.toLowerCase()
|
||||
}
|
||||
|
||||
function fieldMatches(field: ShowactFormField, needles: string[]) {
|
||||
const haystack = fieldHaystack(field)
|
||||
return needles.some((needle) => haystack.includes(needle))
|
||||
}
|
||||
|
||||
function findFieldByIdOrLabel(ids: string[], labels: string[], type?: ShowactFormField['type']) {
|
||||
return findFieldByMatcher((field) => {
|
||||
if (type && field.type !== type) return false
|
||||
const id = field.id.toLowerCase()
|
||||
const label = field.label.toLowerCase()
|
||||
return ids.some((needle) => id.includes(needle)) || labels.some((needle) => label.includes(needle))
|
||||
})
|
||||
}
|
||||
|
||||
function buildShowactPayload(fieldResponsesJson: string, responses: Record<string, string>): CreateShowactApplicationPayload {
|
||||
const artistField = findFieldByMatcher((field) => field.isArtistName)
|
||||
const emailField = findFieldByMatcher((field) => field.type === 'email')
|
||||
const discordField = findFieldByMatcher((field) => fieldMatches(field, ['discord']))
|
||||
const platformField = findFieldByMatcher((field) => {
|
||||
if (field.type !== 'url') return false
|
||||
return fieldMatches(field, ['platform', 'kanal', 'profil', 'channel'])
|
||||
})
|
||||
const referenceField = findFieldByMatcher((field) => {
|
||||
if (field.type !== 'url') return false
|
||||
return fieldMatches(field, ['referenz', 'reference'])
|
||||
})
|
||||
const performanceField = findFieldByIdOrLabel(
|
||||
['performance_type', 'showact_type', 'show_type', 'showact_roles', 'roles'],
|
||||
['showact-art', 'showact art', 'art des showacts', 'performance', 'wofür möchtest', 'wofuer moechtest', 'bewerben'],
|
||||
)
|
||||
const descriptionField = findFieldByIdOrLabel(
|
||||
['description', 'beschreibung', 'show_description'],
|
||||
['beschreibung', 'idee', 'was moechtest', 'was möchtest', 'zeigen'],
|
||||
'textarea',
|
||||
)
|
||||
const technicalNotesField = findFieldByIdOrLabel(
|
||||
['technical_notes', 'technik', 'technical', 'setup', 'timing'],
|
||||
['technik', 'technical', 'setup', 'timing'],
|
||||
)
|
||||
|
||||
const textareaSummary = showactSchema.value
|
||||
.filter((field) => field.type === 'textarea')
|
||||
.map((field) => resolveFieldValue(field, responses))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
const description = resolveFieldValue(descriptionField, responses) || textareaSummary || 'Strukturierte Showact-Bewerbung'
|
||||
|
||||
return {
|
||||
artistName: resolveFieldValue(artistField, responses),
|
||||
contactEmail: resolveFieldValue(emailField, responses),
|
||||
contactDiscord: resolveFieldValue(discordField, responses),
|
||||
platformUrl: resolveFieldValue(platformField, responses),
|
||||
performanceType: resolveFieldValue(performanceField, responses),
|
||||
description,
|
||||
technicalNotes: resolveFieldValue(technicalNotesField, responses),
|
||||
referenceUrl: resolveFieldValue(referenceField, responses),
|
||||
fieldResponsesJson,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShowact() {
|
||||
for (const field of showactSchema.value) {
|
||||
const message = validateField(field)
|
||||
if (message) {
|
||||
fieldErrors[field.id] = message
|
||||
showAdminToast(message, 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
const responses = { ...fieldValues }
|
||||
for (const field of showactSchema.value) {
|
||||
if (!field.allowOther) {
|
||||
continue
|
||||
}
|
||||
|
||||
const otherValue = getOtherValue(field.id)
|
||||
delete responses[otherFieldId(field.id)]
|
||||
if (otherValue) {
|
||||
const current = getMultiselectValues(field.id)
|
||||
responses[field.id] = [...current, `Andere: ${otherValue}`].join(', ')
|
||||
}
|
||||
}
|
||||
const fieldResponsesJson = JSON.stringify(responses)
|
||||
submitting.value = true
|
||||
submitMessage.value = ''
|
||||
submitError.value = ''
|
||||
try {
|
||||
await store.submitShowactApplication(form)
|
||||
submitMessage.value = 'Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.'
|
||||
await store.submitShowactApplication(buildShowactPayload(fieldResponsesJson, responses))
|
||||
showAdminToast('Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.', 'success')
|
||||
resetForm()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.'
|
||||
showAdminToast(error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = 'padding:13px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:\'Outfit\',sans-serif;font-size:15px;color:#3f3556;outline:none;transition:border-color .15s;'
|
||||
const labelStyle = 'font-family:\'Outfit\',sans-serif;font-size:13px;font-weight:600;color:#5f44ad;'
|
||||
const errorStyle = 'font-family:\'Outfit\',sans-serif;font-size:12px;font-weight:600;color:#d14b72;line-height:1.45;'
|
||||
const checkboxStyle = 'display:flex;align-items:center;gap:9px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:\'Outfit\',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;'
|
||||
|
||||
function onFocus(event: FocusEvent) {
|
||||
;(event.target as HTMLElement).style.borderColor = '#8b6cdb'
|
||||
}
|
||||
function onBlur(event: FocusEvent) {
|
||||
;(event.target as HTMLElement).style.borderColor = '#e6dcf6'
|
||||
}
|
||||
|
||||
function handleFieldBlur(field: ShowactFormField, event: FocusEvent) {
|
||||
onBlur(event)
|
||||
updateFieldError(field)
|
||||
}
|
||||
|
||||
function resolveTextInputType(field: ShowactFormField) {
|
||||
if (field.type === 'email' || field.type === 'url') {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function resolveInputMode(field: ShowactFormField) {
|
||||
if (field.type === 'email') return 'email'
|
||||
if (field.type === 'url') return 'url'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function resolveAutoComplete(field: ShowactFormField) {
|
||||
if (field.type === 'email') return 'email'
|
||||
if (field.type === 'url') return 'url'
|
||||
return 'off'
|
||||
}
|
||||
|
||||
function isSafePublicUrl(value: string | null | undefined) {
|
||||
if (!value) return false
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modalOpen,
|
||||
(next) => {
|
||||
if (!next) {
|
||||
localModalOpen.value = false
|
||||
showactInfoModalOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (featureFlags.value.showactApplicationsEnabled) {
|
||||
localModalOpen.value = true
|
||||
showactInfoModalOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
localModalOpen.value = false
|
||||
showactInfoModalOpen.value = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="showSection" class="mx-auto grid w-full max-w-6xl gap-5 px-5 py-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<section v-if="showSection" class="mx-auto w-full max-w-[1200px] space-y-5 px-6 pb-6 -mt-10">
|
||||
<!-- Sponsor-Kacheln -->
|
||||
<div v-if="sponsors.length > 0" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-sky-100 text-sky-700">
|
||||
@@ -64,8 +413,7 @@ async function submitShowact() {
|
||||
<h2 class="text-xl font-black text-[#3f3556]">Sponsoren</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
v-for="sponsor in sponsors"
|
||||
:key="sponsor.id"
|
||||
@@ -89,48 +437,296 @@ async function submitShowact() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="featureFlags.showactApplicationsEnabled" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-fuchsia-100 text-fuchsia-700">
|
||||
<Mic2 class="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.2em] text-fuchsia-600">Showacts</p>
|
||||
<h2 class="text-xl font-black text-[#3f3556]">Bewerben</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mt-5 space-y-3" @submit.prevent="submitShowact">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input v-model="form.artistName" required maxlength="120" placeholder="Kuenstlername" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="form.performanceType" required maxlength="80" placeholder="Showact-Art" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input v-model="form.contactEmail" maxlength="180" placeholder="E-Mail" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="form.contactDiscord" maxlength="120" placeholder="Discord" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input v-model="form.platformUrl" maxlength="500" placeholder="Kanal/Profil URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="form.referenceUrl" maxlength="500" placeholder="Referenz URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
<textarea v-model="form.description" required rows="3" maxlength="1000" placeholder="Kurz beschreiben, was du zeigen moechtest" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<textarea v-model="form.technicalNotes" rows="2" maxlength="1000" placeholder="Technische Hinweise, Setup, Timing" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
|
||||
<p v-if="submitError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ submitError }}</p>
|
||||
<p v-if="submitMessage" class="flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
{{ submitMessage }}
|
||||
<!-- Showact-Banner -->
|
||||
<div
|
||||
v-if="showactVisible"
|
||||
class="flex flex-wrap items-center gap-5 rounded-[24px] border border-white/70 bg-white/78 px-6 py-5 backdrop-blur sm:flex-nowrap"
|
||||
>
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Mic2 class="h-6 w-6" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.2em] text-violet-500">Showacts</p>
|
||||
<h2 class="mt-0.5 text-xl font-black text-[#3f3556]">Als Showact auftreten</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-[#746b86]">
|
||||
<template v-if="featureFlags.showactApplicationsEnabled">
|
||||
Hast du eine Idee fuer einen Beitrag? Bewirb dich jetzt und zeig der Community, was du kannst.
|
||||
</template>
|
||||
<template v-else>
|
||||
Showacts bleiben ein Teil der Award-Show. Aktuell ist das Bewerbungsfenster geschlossen, aber alle Infos bleiben hier erreichbar.
|
||||
</template>
|
||||
<span v-if="showactDeadlineText" class="font-semibold text-violet-700">{{ showactDeadlineText }}</span>
|
||||
<span v-else class="font-semibold text-violet-700">{{ showactStatusText }}</span>
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-fuchsia-500 bg-fuchsia-600 px-5 text-sm font-black text-white shadow-[0_14px_28px_rgba(192,38,211,0.2)] transition hover:bg-fuchsia-500 disabled:opacity-60"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<a
|
||||
v-if="showactPrimaryHref"
|
||||
:href="showactPrimaryHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
style="display:inline-flex;align-items:center;gap:9px;height:44px;padding:0 20px;border-radius:14px;background:#8b6cdb;border:none;color:#fff;font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;cursor:pointer;box-shadow:0 4px 14px rgba(124,86,196,.25);transition:transform .15s,box-shadow .15s;flex-shrink:0;text-decoration:none;"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
{{ showactPrimaryLabel }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:disabled="showactPrimaryDisabled"
|
||||
:style="showactPrimaryButtonStyle"
|
||||
@click="openShowactPrimaryAction"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
{{ showactPrimaryLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bewerbungs-Modal im Landing-Page-Stil -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="localModalOpen"
|
||||
style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div
|
||||
style="position:relative;width:100%;max-width:720px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Schliessen-Button -->
|
||||
<button
|
||||
aria-label="Schliessen"
|
||||
style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(255,255,255,.14);color:rgba(255,255,255,.8);font-size:18px;cursor:pointer;"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- Header mit Sternanimation -->
|
||||
<div style="position:relative;overflow:hidden;padding:30px 36px 26px;border-bottom:1px solid rgba(255,255,255,.12);background:linear-gradient(135deg,#2a1842,#3a2168);flex:none;">
|
||||
<HomeStarField :count="13" variant="header" />
|
||||
<div style="position:relative;z-index:1;">
|
||||
<div style="display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;">✦ Showacts</div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(26px,3vw,34px);line-height:1.1;margin:0 0 8px;color:#fff6fb;">Showact-Bewerbung</h3>
|
||||
<p style="font-size:15px;line-height:1.55;color:#d8c9eb;margin:0;">Fuell das Formular aus und schick deine Bewerbung ab.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamisches Formular -->
|
||||
<div style="overflow-y:auto;padding:28px 36px 32px;flex:1;">
|
||||
<form novalidate @submit.prevent="submitShowact">
|
||||
<template v-for="field in showactSchema" :key="field.id">
|
||||
<!-- text / email / url -->
|
||||
<label v-if="field.type === 'text' || field.type === 'email' || field.type === 'url'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<input
|
||||
v-model="fieldValues[field.id]"
|
||||
:type="resolveTextInputType(field)"
|
||||
:inputmode="resolveInputMode(field)"
|
||||
:autocomplete="resolveAutoComplete(field)"
|
||||
:maxlength="field.maxLength"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="fieldErrors[field.id] ? 'true' : 'false'"
|
||||
:style="inputStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')"
|
||||
@focus="onFocus"
|
||||
@blur="handleFieldBlur(field, $event)"
|
||||
@input="delete fieldErrors[field.id]"
|
||||
/>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</label>
|
||||
|
||||
<!-- textarea -->
|
||||
<label v-else-if="field.type === 'textarea'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<textarea
|
||||
v-model="fieldValues[field.id]"
|
||||
rows="4"
|
||||
:maxlength="field.maxLength"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="fieldErrors[field.id] ? 'true' : 'false'"
|
||||
:style="inputStyle + 'resize:vertical;line-height:1.55;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')"
|
||||
@focus="onFocus"
|
||||
@blur="handleFieldBlur(field, $event)"
|
||||
@input="delete fieldErrors[field.id]"
|
||||
/>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</label>
|
||||
|
||||
<!-- select -->
|
||||
<label v-else-if="field.type === 'select'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<select
|
||||
v-model="fieldValues[field.id]"
|
||||
:aria-invalid="fieldErrors[field.id] ? 'true' : 'false'"
|
||||
:style="inputStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')"
|
||||
@focus="onFocus"
|
||||
@blur="handleFieldBlur(field, $event)"
|
||||
@change="delete fieldErrors[field.id]"
|
||||
>
|
||||
<option value="">{{ field.placeholder || '— Bitte wählen —' }}</option>
|
||||
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</label>
|
||||
|
||||
<!-- radio -->
|
||||
<div v-else-if="field.type === 'radio'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<div :style="'display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
|
||||
<label
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
|
||||
>
|
||||
<input
|
||||
v-model="fieldValues[field.id]"
|
||||
type="radio"
|
||||
:name="field.id"
|
||||
:value="opt"
|
||||
style="width:17px;height:17px;accent-color:#7c56c4;"
|
||||
@change="delete fieldErrors[field.id]"
|
||||
/>
|
||||
{{ opt }}
|
||||
</label>
|
||||
</div>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- multiselect (checkboxes) -->
|
||||
<div v-else-if="field.type === 'multiselect'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<div :style="'display:flex;flex-direction:column;gap:6px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
|
||||
<label
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
style="display:flex;align-items:center;gap:8px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="getMultiselectValues(field.id).includes(opt)"
|
||||
style="width:16px;height:16px;"
|
||||
@change="setMultiselectValue(field.id, opt, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
|
||||
/>
|
||||
{{ opt }}
|
||||
</label>
|
||||
<label
|
||||
v-if="field.allowOther"
|
||||
style="display:flex;align-items:center;gap:8px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
|
||||
>
|
||||
<span style="width:16px;height:16px;border-radius:4px;border:1.5px solid #d8c9eb;background:#fff;"></span>
|
||||
Andere:
|
||||
<input
|
||||
:value="getOtherValue(field.id)"
|
||||
type="text"
|
||||
placeholder="Eigene Antwort"
|
||||
style="min-width:0;flex:1;border:0;border-bottom:1.5px solid #d8c9eb;background:transparent;padding:4px 2px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;outline:none;"
|
||||
@input="updateOtherValue(field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span v-if="field.options.length === 0" style="font-size:13px;color:#9ca3af;">Keine Optionen konfiguriert.</span>
|
||||
</div>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- single checkbox confirmation -->
|
||||
<div v-else-if="field.type === 'checkbox'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<label :style="checkboxStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isCheckboxChecked(field)"
|
||||
style="width:17px;height:17px;accent-color:#7c56c4;"
|
||||
@change="setCheckboxValue(field, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
|
||||
/>
|
||||
{{ field.options[0] || 'Ja' }}
|
||||
</label>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:6px;">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
style="display:inline-flex;align-items:center;gap:8px;padding:14px 28px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);transition:transform .15s,opacity .15s;"
|
||||
@mouseover="($event.currentTarget as HTMLButtonElement).style.transform='translateY(-2px)'"
|
||||
@mouseleave="($event.currentTarget as HTMLButtonElement).style.transform='translateY(0)'"
|
||||
>
|
||||
<Send :size="16" />
|
||||
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showactInfoModalOpen"
|
||||
style="position:fixed;inset:0;z-index:205;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
@click.self="closeShowactInfoModal"
|
||||
>
|
||||
<div
|
||||
style="position:relative;width:100%;max-width:720px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
aria-label="Schliessen"
|
||||
style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;"
|
||||
@click="closeShowactInfoModal"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<div style="padding:30px 36px 20px;border-bottom:1px solid #f1ecfb;background:linear-gradient(135deg,#2a1842,#3a2168);">
|
||||
<p style="margin:0 0 8px;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#c9b1ff;">Showacts</p>
|
||||
<h3 style="margin:0;font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;line-height:1.1;color:#fff6fb;">Infos zur Award-Show</h3>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 36px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||
<div v-if="showactInfoHtml" class="home-showact-info-content" v-html="showactInfoHtml" />
|
||||
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
|
||||
Fuer Showacts ist aktuell noch kein Landingpage-Inhalt hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-showact-info-content :deep(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.home-showact-info-content :deep(p:last-child),
|
||||
.home-showact-info-content :deep(ul:last-child),
|
||||
.home-showact-info-content :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-showact-info-content :deep(ul),
|
||||
.home-showact-info-content :deep(ol) {
|
||||
margin: 0 0 14px 20px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.home-showact-info-content :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 560px) {
|
||||
.home-showact-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,16 +102,17 @@ function syncNominationDrafts(options: HomeSelectionOption[]) {
|
||||
const nextVisibleLinkCounts: Record<number, number> = {}
|
||||
for (const option of options) {
|
||||
const existing = nominationDrafts.value[option.id] ?? []
|
||||
nextDrafts[option.id] = [existing[0] ?? '', existing[1] ?? '', existing[2] ?? '']
|
||||
const limit = nominationLinkLimitFor(option.id)
|
||||
nextDrafts[option.id] = createNominationLinkSlots(existing, limit)
|
||||
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
|
||||
nextVisibleLinkCounts[option.id] = Math.min(3, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
|
||||
nextVisibleLinkCounts[option.id] = Math.min(limit, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
|
||||
}
|
||||
nominationDrafts.value = nextDrafts
|
||||
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
|
||||
}
|
||||
|
||||
function nominationLinksFor(categoryIndex: number) {
|
||||
return nominationDrafts.value[categoryIndex] ?? ['', '', '']
|
||||
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
|
||||
}
|
||||
|
||||
function nominationVisibleLinkCountFor(categoryIndex: number) {
|
||||
@@ -119,9 +120,10 @@ function nominationVisibleLinkCountFor(categoryIndex: number) {
|
||||
}
|
||||
|
||||
function compactNominationCategory(categoryIndex: number) {
|
||||
const limit = nominationLinkLimitFor(categoryIndex)
|
||||
const links = nominationLinksFor(categoryIndex)
|
||||
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, 3)
|
||||
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
|
||||
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, limit)
|
||||
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
|
||||
const nextVisibleCount = Math.max(1, compactedLinks.length)
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
@@ -162,12 +164,13 @@ const activeNominationCategory = computed(() =>
|
||||
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
|
||||
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
|
||||
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
|
||||
const activeNominationRemainingLinks = computed(() => Math.max(0, 3 - activeNominationVisibleLinkCount.value))
|
||||
const activeNominationLinkLimit = computed(() => nominationLinkLimitFor(nominationCatValue.value))
|
||||
const activeNominationRemainingLinks = computed(() => Math.max(0, activeNominationLinkLimit.value - activeNominationVisibleLinkCount.value))
|
||||
const nominationReviewItems = computed(() =>
|
||||
props.catOptions
|
||||
.map((option) => ({
|
||||
categoryIndex: option.id,
|
||||
categoryName: stripCategoryIcon(option.label),
|
||||
categoryName: option.name || stripCategoryIcon(option.label),
|
||||
links: nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean),
|
||||
}))
|
||||
.filter((item) => item.links.length > 0),
|
||||
@@ -184,8 +187,9 @@ const nominationRailItems = computed(() => props.catOptions.map((option) => {
|
||||
const isActive = option.id === nominationCatValue.value
|
||||
return {
|
||||
id: option.id,
|
||||
name: stripCategoryIcon(option.label),
|
||||
icon: option.label.trim().split(' ')[0] ?? '✦',
|
||||
name: option.name || stripCategoryIcon(option.label),
|
||||
groupName: option.groupName,
|
||||
icon: option.icon || option.label.trim().split(' ')[0] || '✦',
|
||||
status: errors.some(Boolean) ? 'Fehler' : linkCount === 0 ? 'Leer' : `${linkCount} Link${linkCount === 1 ? '' : 's'}`,
|
||||
tone: errors.some(Boolean) ? 'error' as const : linkCount > 0 ? 'done' as const : isActive ? 'active' as const : 'empty' as const,
|
||||
}
|
||||
@@ -237,9 +241,10 @@ function editNominationCategory(categoryIndex: number) {
|
||||
|
||||
function addNominationLinkField() {
|
||||
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[nominationCatValue.value]: Math.min(3, currentCount + 1),
|
||||
[nominationCatValue.value]: Math.min(limit, currentCount + 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +254,11 @@ function removeNominationLinkField(index: number) {
|
||||
.filter((_, linkIndex) => linkIndex !== index)
|
||||
.map((link) => link.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
|
||||
.slice(0, nominationLinkLimitFor(nominationCatValue.value))
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
|
||||
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const nextVisibleCount = Math.max(1, Math.min(3, Math.max(previousVisibleCount - 1, compactedLinks.length)))
|
||||
const nextVisibleCount = Math.max(1, Math.min(limit, Math.max(previousVisibleCount - 1, compactedLinks.length)))
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
[nominationCatValue.value]: nextLinks,
|
||||
@@ -286,6 +292,16 @@ function normalizeUrlForCompare(value: string) {
|
||||
return value.trim().replace(/\/+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
function nominationLinkLimitFor(categoryIndex: number) {
|
||||
const rawLimit = props.catOptions.find((option) => option.id === categoryIndex)?.maxNomineesPerUser
|
||||
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) ? rawLimit : 3
|
||||
return Math.max(1, Math.min(10, Math.round(limit)))
|
||||
}
|
||||
|
||||
function createNominationLinkSlots(existing: string[], limit: number) {
|
||||
return Array.from({ length: limit }, (_, index) => existing[index] ?? '')
|
||||
}
|
||||
|
||||
function stripCategoryIcon(value: string) {
|
||||
return value.replace(/^\S+\s+/, '')
|
||||
}
|
||||
@@ -294,7 +310,7 @@ function stripCategoryIcon(value: string) {
|
||||
<template>
|
||||
<template v-if="props.modalOpen">
|
||||
<div class="home-modal-overlay" @click="props.closeModal" style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
||||
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.isVote && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);">✕</button>
|
||||
|
||||
<template v-if="props.submitted">
|
||||
@@ -309,13 +325,6 @@ function stripCategoryIcon(value: string) {
|
||||
</template>
|
||||
|
||||
<template v-if="props.notSubmitted">
|
||||
<p
|
||||
v-if="props.formError"
|
||||
style="margin:18px 36px 0;padding:12px 14px;border-radius:12px;border:1px solid #fecdd3;background:#fff1f2;color:#be123c;font-size:13.5px;font-weight:600;line-height:1.45;"
|
||||
>
|
||||
{{ props.formError }}
|
||||
</p>
|
||||
|
||||
<template v-if="props.isShow">
|
||||
<div class="home-modal__show" style="padding:38px 40px 40px;">
|
||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
|
||||
@@ -338,7 +347,7 @@ function stripCategoryIcon(value: string) {
|
||||
<div class="home-wizard-header-row">
|
||||
<div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">Streamer nominieren</h3>
|
||||
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.</p>
|
||||
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie die erlaubte Anzahl an Stream- oder Kanal-Links ein.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,7 +368,7 @@ function stripCategoryIcon(value: string) {
|
||||
/>
|
||||
<HomeNominationWizardPane
|
||||
v-else
|
||||
:category-name="activeNominationCategory ? stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||
:links="activeNominationLinks"
|
||||
:errors="activeNominationErrors"
|
||||
:visible-link-count="activeNominationVisibleLinkCount"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CinematicStarLoader from '../CinematicStarLoader.vue'
|
||||
import HomeCategoriesSection from './HomeCategoriesSection.vue'
|
||||
@@ -14,7 +15,8 @@ import { useHomeLandingState } from './useHomeLandingState'
|
||||
import { useHomeLandingViewEffects } from './useHomeLandingViewEffects'
|
||||
|
||||
const router = useRouter()
|
||||
const rootStyle = "font-family:'Outfit',sans-serif;min-height:100vh;overflow-x:hidden;line-height:1.5;position:relative;background:#f4eefb;color:#3f3556;--bg:#f4eefb;--bg2:#ebe2f8;--surface:#ffffff;--card:#ffffff;--ink:#3f3556;--muted:#8a8398;--accent:#8b6cdb;--accent2:#b78bff;--gold:#d9942a;--line:rgba(124,86,196,0.16);--glow:rgba(139,108,219,0.3)"
|
||||
const showactModalOpen = ref(false)
|
||||
const rootStyle ="font-family:'Outfit',sans-serif;min-height:100vh;overflow-x:hidden;line-height:1.5;position:relative;background:#f4eefb;color:#3f3556;--bg:#f4eefb;--bg2:#ebe2f8;--surface:#ffffff;--card:#ffffff;--ink:#3f3556;--muted:#8a8398;--accent:#8b6cdb;--accent2:#b78bff;--gold:#d9942a;--line:rgba(124,86,196,0.16);--glow:rgba(139,108,219,0.3)"
|
||||
const {
|
||||
store,
|
||||
authStore,
|
||||
@@ -30,7 +32,7 @@ const {
|
||||
faqItems,
|
||||
privacyContentHtml,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
preparationPhase,
|
||||
@@ -271,7 +273,7 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
|
||||
:on-timeline-final-action="onTimelineFinalAction"
|
||||
/>
|
||||
|
||||
<HomeCategoriesSection :display-categories="displayCategories" />
|
||||
<HomeCategoriesSection :landing-categories="landingCategories" />
|
||||
|
||||
<HomeWinnerShowcaseSection
|
||||
:selected-archive="selectedArchive"
|
||||
@@ -292,7 +294,7 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
|
||||
:on-section-action="onSectionAction"
|
||||
/>
|
||||
|
||||
<HomeExtrasSection />
|
||||
<HomeExtrasSection v-model:modal-open="showactModalOpen" />
|
||||
|
||||
<HomeSupportFooterSection
|
||||
:community-social-links="communitySocialLinks"
|
||||
@@ -300,6 +302,7 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
|
||||
:faq-items="faqItems"
|
||||
:footer-links="footerLinks"
|
||||
:on-open-privacy="onOpenPrivacy"
|
||||
:on-showact-apply="() => { showactModalOpen = true }"
|
||||
:is-uploaded-social-icon="isUploadedSocialIcon"
|
||||
:social-simple-icon-path="socialSimpleIconPath"
|
||||
:social-simple-icon-color="socialSimpleIconColor"
|
||||
|
||||
@@ -29,7 +29,7 @@ const props = defineProps<{
|
||||
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">1</span>
|
||||
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Nominieren</h3>
|
||||
</div>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie bis zu 3 Stream- oder Kanal-Links.</p>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie - pro Kategorie bis zur erlaubten Anzahl an Stream- oder Kanal-Links.</p>
|
||||
</div>
|
||||
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<div style="flex:1;">
|
||||
|
||||
@@ -19,6 +19,8 @@ interface FooterLink {
|
||||
|
||||
interface SiteContent {
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
}
|
||||
|
||||
interface FaqItem {
|
||||
@@ -32,6 +34,7 @@ const props = defineProps<{
|
||||
faqItems: FaqItem[]
|
||||
footerLinks: FooterLink[]
|
||||
onOpenPrivacy: () => void
|
||||
onShowactApply?: () => void
|
||||
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
|
||||
socialSimpleIconPath: (platform: string | null | undefined) => string
|
||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||
@@ -39,12 +42,18 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const activeFooterLink = ref<FooterLink | null>(null)
|
||||
const linkCopied = ref(false)
|
||||
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
|
||||
const safeNewsletterUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
|
||||
)
|
||||
|
||||
function openFooterLink(event: Event, link: FooterLink) {
|
||||
if (link.key === 'showacts' && props.onShowactApply) {
|
||||
event.preventDefault()
|
||||
props.onShowactApply()
|
||||
return
|
||||
}
|
||||
if (!link.content.trim()) {
|
||||
if (!link.url.trim()) {
|
||||
event.preventDefault()
|
||||
@@ -60,6 +69,16 @@ function closeFooterLink() {
|
||||
activeFooterLink.value = null
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
linkCopied.value = true
|
||||
setTimeout(() => { linkCopied.value = false }, 2000)
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
}
|
||||
|
||||
function isSafePublicUrl(value: string | null | undefined) {
|
||||
if (!value) return false
|
||||
|
||||
@@ -120,9 +139,9 @@ function isSafePublicUrl(value: string | null | undefined) {
|
||||
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
|
||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>Link kopieren</a>
|
||||
<a v-if="props.siteContent.shareXUrl" :href="props.siteContent.shareXUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a v-if="props.siteContent.shareDiscordUrl" :href="props.siteContent.shareDiscordUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a href="#" @click.prevent="copyLink" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>{{ linkCopied ? 'Kopiert ✓' : 'Link kopieren' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,16 +212,6 @@ function isSafePublicUrl(value: string | null | undefined) {
|
||||
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
|
||||
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
|
||||
</p>
|
||||
<a
|
||||
v-if="activeFooterLink.url"
|
||||
:href="activeFooterLink.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="display:inline-flex;align-items:center;gap:9px;margin-top:18px;padding:12px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #e6dcf6;color:#6a4fb8;text-decoration:none;font-weight:700;"
|
||||
style-hover="background:#f1ecfb;"
|
||||
>
|
||||
Externe Seite öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div :style="nominationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ nominationPhase ? 'JETZT AKTIV' : 'ABGESCHLOSSEN' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Nominierung</h3>
|
||||
<div style="font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;">{{ formatTimelineRange('nomination') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein — pro Kategorie bis zu drei Nominierungen.</p>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein - pro Kategorie bis zum konfigurierten Limit.</p>
|
||||
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren' : 'Nominierungen ansehen' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import HomeMissingVotesConfirm from './HomeMissingVotesConfirm.vue'
|
||||
import HomeWizardFooter from './HomeWizardFooter.vue'
|
||||
@@ -20,11 +20,38 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const confirmMissingVotes = ref(false)
|
||||
const railRef = ref<HTMLElement | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryRef = ref<HTMLElement | null>(null)
|
||||
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.name))
|
||||
const activeCategoryItem = computed(() => props.catList[props.activeCatIndex] ?? null)
|
||||
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.displayName))
|
||||
const submitLabel = computed(() => props.savedVoteActive ? 'Änderungen speichern' : 'Stimmen absenden')
|
||||
const isLastCategory = computed(() => props.activeCatIndex >= props.catList.length - 1)
|
||||
const groupedCatList = computed(() => {
|
||||
const groups: Array<{
|
||||
name: string
|
||||
items: HomeCategoryListItem[]
|
||||
doneCount: number
|
||||
candidateCount: number
|
||||
}> = []
|
||||
for (const category of props.catList) {
|
||||
const groupName = category.groupName?.trim() || 'Weitere Kategorien'
|
||||
const current = groups.at(-1)
|
||||
if (current?.name === groupName) {
|
||||
current.items.push(category)
|
||||
current.doneCount += category.done ? 1 : 0
|
||||
current.candidateCount += category.candidateCount
|
||||
} else {
|
||||
groups.push({
|
||||
name: groupName,
|
||||
items: [category],
|
||||
doneCount: category.done ? 1 : 0,
|
||||
candidateCount: category.candidateCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
const footerPrimaryLabel = computed(() => {
|
||||
if (!isLastCategory.value) return 'Weiter'
|
||||
if (!props.canSubmitVote) return 'Erst Favorit:in wählen'
|
||||
@@ -81,10 +108,11 @@ function hideMissingVoteConfirm() {
|
||||
|
||||
watch(
|
||||
() => props.activeCatIndex,
|
||||
() => {
|
||||
async () => {
|
||||
confirmMissingVotes.value = false
|
||||
await nextTick()
|
||||
contentRef.value?.scrollTo({ top: 0 })
|
||||
activeCategoryRef.value?.scrollIntoView({ block: 'nearest', inline: 'center' })
|
||||
syncActiveCategoryIntoRail()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -94,6 +122,23 @@ function setCategoryButtonRef(element: unknown, categoryIndex: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveCategoryIntoRail() {
|
||||
const rail = railRef.value
|
||||
const activeButton = activeCategoryRef.value
|
||||
if (!rail || !activeButton) return
|
||||
|
||||
const isHorizontalRail = rail.scrollWidth > rail.clientWidth && rail.clientWidth >= rail.clientHeight
|
||||
if (isHorizontalRail) {
|
||||
const buttonCenter = activeButton.offsetLeft + activeButton.offsetWidth / 2
|
||||
const targetLeft = Math.max(0, buttonCenter - rail.clientWidth / 2)
|
||||
rail.scrollTo({ left: targetLeft })
|
||||
return
|
||||
}
|
||||
|
||||
const targetTop = Math.max(0, activeButton.offsetTop - Math.max(12, (rail.clientHeight - activeButton.offsetHeight) / 2))
|
||||
rail.scrollTo({ top: targetTop })
|
||||
}
|
||||
|
||||
function goToNextCategory() {
|
||||
if (isLastCategory.value) {
|
||||
if (!props.canSubmitVote) return
|
||||
@@ -114,20 +159,28 @@ async function submitConfirmedVote() {
|
||||
|
||||
<template>
|
||||
<div class="home-vote-picker">
|
||||
<aside class="home-vote-picker__rail">
|
||||
<div class="home-vote-picker__rail-label">Kategorien</div>
|
||||
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
|
||||
<aside ref="railRef" class="home-vote-picker__rail">
|
||||
<div class="home-vote-picker__rail-label">Hauptkategorien</div>
|
||||
<section v-for="group in groupedCatList" :key="group.name" class="home-vote-picker__rail-group">
|
||||
<div class="home-vote-picker__rail-group-label">{{ group.name }}</div>
|
||||
<button
|
||||
:ref="(element) => setCategoryButtonRef(element, __idx)"
|
||||
v-for="cat in group.items"
|
||||
:key="cat.idx"
|
||||
:ref="(element) => setCategoryButtonRef(element, cat.idx)"
|
||||
type="button"
|
||||
class="home-vote-picker__category-button"
|
||||
:aria-current="cat.idx === props.activeCatIndex ? 'true' : undefined"
|
||||
@click="cat.onClick"
|
||||
@pointerdown.prevent
|
||||
:style="cat.rowStyle"
|
||||
>
|
||||
<span :style="cat.iconStyle">{{ cat.icon }}</span>
|
||||
<span>{{ cat.name }}</span>
|
||||
<span class="home-vote-picker__category-copy">
|
||||
<span class="home-vote-picker__category-tier">{{ cat.name }}</span>
|
||||
</span>
|
||||
<span v-if="!props.readonlyMode" :style="cat.checkStyle">✓</span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section ref="contentRef" class="home-vote-picker__content">
|
||||
@@ -143,7 +196,8 @@ async function submitConfirmedVote() {
|
||||
<template v-else>
|
||||
<header class="home-vote-picker__category-header">
|
||||
<div>
|
||||
<h4>{{ props.activeCatName }}</h4>
|
||||
<p v-if="activeCategoryItem?.groupName" class="home-vote-picker__eyebrow">{{ activeCategoryItem.groupName }}</p>
|
||||
<h4>{{ activeCategoryItem?.name || props.activeCatName }}</h4>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -330,8 +330,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
position:relative;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
height:min(760px,calc(100dvh - 48px));
|
||||
max-height:88vh;
|
||||
flex:1;
|
||||
min-height:0;
|
||||
overflow:hidden;
|
||||
background:
|
||||
@@ -405,6 +404,30 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
color:#a98ddb;
|
||||
}
|
||||
|
||||
.home-wizard-rail__group,
|
||||
.home-vote-picker__rail-group{
|
||||
display:grid;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
.home-wizard-rail__group + .home-wizard-rail__group,
|
||||
.home-vote-picker__rail-group + .home-vote-picker__rail-group{
|
||||
margin-top:8px;
|
||||
padding-top:10px;
|
||||
border-top:1px solid rgba(139,108,219,.12);
|
||||
}
|
||||
|
||||
.home-wizard-rail__group-label,
|
||||
.home-vote-picker__rail-group-label{
|
||||
padding:0 4px;
|
||||
color:#8b6cdb;
|
||||
font-size:10.5px;
|
||||
font-weight:900;
|
||||
letter-spacing:.14em;
|
||||
line-height:1.2;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
|
||||
.home-wizard-rail__button{
|
||||
display:grid;
|
||||
grid-template-columns:auto minmax(0,1fr) auto;
|
||||
@@ -855,6 +878,11 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
scrollbar-gutter:stable;
|
||||
}
|
||||
|
||||
.home-vote-picker__rail-group{
|
||||
display:grid;
|
||||
gap:6px;
|
||||
}
|
||||
|
||||
.home-vote-picker__rail-label{
|
||||
margin:0 4px 6px;
|
||||
font-size:11px;
|
||||
@@ -868,11 +896,32 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
min-height:54px;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-copy{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:2px;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-tier{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-meta{
|
||||
color:#9a91aa;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
line-height:1.2;
|
||||
}
|
||||
|
||||
.home-vote-picker__content{
|
||||
min-width:0;
|
||||
padding:16px 22px 20px;
|
||||
overflow-y:auto;
|
||||
min-height:0;
|
||||
min-block-size:420px;
|
||||
scrollbar-gutter:stable;
|
||||
background:rgba(255,255,255,.18);
|
||||
}
|
||||
@@ -888,6 +937,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
padding:0 0 12px;
|
||||
border-bottom:1px solid rgba(139,108,219,.09);
|
||||
background:transparent;
|
||||
min-height:74px;
|
||||
}
|
||||
|
||||
.home-vote-picker__eyebrow{
|
||||
@@ -913,20 +963,10 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
padding-bottom:22px;
|
||||
}
|
||||
|
||||
.home-vote-picker__hint{
|
||||
max-width:230px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
background:#f4eefd;
|
||||
color:#7d68bd;
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
line-height:1.35;
|
||||
}
|
||||
|
||||
.home-vote-picker__cards{
|
||||
display:grid;
|
||||
gap:12px;
|
||||
align-content:start;
|
||||
}
|
||||
|
||||
.home-nomination-summary-card{
|
||||
@@ -1007,13 +1047,12 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
}
|
||||
|
||||
.home-vote-card:hover{
|
||||
border-color:rgba(139,108,219,.24);
|
||||
box-shadow:0 16px 36px rgba(139,108,219,.14);
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.home-vote-card--selected{
|
||||
border-color:#8b6cdb;
|
||||
border:3px solid #7c4de8;
|
||||
background:
|
||||
radial-gradient(circle at 92% 16%,rgba(139,108,219,.16),transparent 34%),
|
||||
rgba(255,255,255,.92);
|
||||
@@ -1421,6 +1460,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
|
||||
.home-vote-picker__content{
|
||||
padding:14px 18px 18px;
|
||||
min-block-size:360px;
|
||||
}
|
||||
|
||||
.home-vote-card{
|
||||
@@ -1961,6 +2001,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
.home-vote-picker__content{
|
||||
flex:1 1 auto!important;
|
||||
min-height:0!important;
|
||||
min-block-size:0!important;
|
||||
padding:14px!important;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,20 @@ export interface HomeNominationSubmitContext {
|
||||
|
||||
export interface HomeDisplayCategory {
|
||||
id: string
|
||||
groupName: string
|
||||
name: string
|
||||
icon: string
|
||||
maxNomineesPerUser: number
|
||||
candidates: CandidateSummary[]
|
||||
}
|
||||
|
||||
export interface HomeLandingCategory {
|
||||
id: string
|
||||
groupName: string
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
maxNomineesPerUser: number
|
||||
candidateCount: number
|
||||
subcategoryCount: number
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface HomeCategoryListItem {
|
||||
name: string
|
||||
groupName: string
|
||||
displayName: string
|
||||
icon: string
|
||||
idx: number
|
||||
done: boolean
|
||||
candidateCount: number
|
||||
onClick: () => void
|
||||
rowStyle: string
|
||||
iconStyle: string
|
||||
@@ -33,6 +36,10 @@ export interface HomeNomineeListItem {
|
||||
export interface HomeSelectionOption {
|
||||
id: number
|
||||
label: string
|
||||
name?: string
|
||||
groupName?: string
|
||||
icon?: string
|
||||
maxNomineesPerUser?: number
|
||||
}
|
||||
|
||||
export interface HomeArchiveYearItem {
|
||||
|
||||
@@ -63,7 +63,9 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
|
||||
const clipUrl = winner.clipUrl?.trim() || null
|
||||
const clipTitle = winner.clipTitle?.trim() || 'Gewinner-Clip ansehen'
|
||||
const clipPlatform = clipUrl ? winner.clipPlatform?.trim() || winnerPlatformLabel(clipUrl) : 'Kein Clip'
|
||||
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
|
||||
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' && winner.clipEmbedStatus !== 'blocked'
|
||||
? buildClipEmbed(clipUrl)
|
||||
: null
|
||||
return {
|
||||
category: winner.category,
|
||||
name: winner.winnerName,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
import { buildClipEmbed } from '../../lib/clipEmbeds'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind, HomeLandingCategory } from './homeLandingTypes'
|
||||
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
@@ -31,11 +31,52 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
|
||||
const displayCategories = computed<HomeDisplayCategory[]>(() =>
|
||||
store.categories.categories.map((category, index) => ({
|
||||
id: String(category.id),
|
||||
groupName: category.groupName,
|
||||
name: category.name,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
candidates: category.candidates,
|
||||
})),
|
||||
)
|
||||
const landingCategories = computed<HomeLandingCategory[]>(() => {
|
||||
if (store.overview.featuredCategories.length > 0) {
|
||||
return store.overview.featuredCategories.map((category, index) => {
|
||||
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName === category.groupName)
|
||||
const candidateCount = matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0)
|
||||
return {
|
||||
id: String(category.id),
|
||||
groupName: category.groupName,
|
||||
name: category.groupName,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
description: category.description,
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
candidateCount,
|
||||
subcategoryCount: matchingSubcategories.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const seenGroups = new Set<string>()
|
||||
return displayCategories.value.flatMap((category, index) => {
|
||||
const groupKey = category.groupName.trim().toLowerCase()
|
||||
if (seenGroups.has(groupKey)) {
|
||||
return []
|
||||
}
|
||||
|
||||
seenGroups.add(groupKey)
|
||||
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName.trim().toLowerCase() === groupKey)
|
||||
return [{
|
||||
id: category.id,
|
||||
groupName: category.groupName,
|
||||
name: category.groupName,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
description: '',
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
candidateCount: matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0),
|
||||
subcategoryCount: matchingSubcategories.length,
|
||||
}]
|
||||
})
|
||||
})
|
||||
const candidateCount = computed(() =>
|
||||
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
|
||||
)
|
||||
@@ -82,6 +123,7 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
bootstrapArchiveYears,
|
||||
formatRange,
|
||||
@@ -194,9 +236,12 @@ function buildCatList(
|
||||
const done = votes.value[category.id] != null
|
||||
return {
|
||||
name: category.name,
|
||||
groupName: category.groupName,
|
||||
displayName: `${category.groupName} · ${category.name}`,
|
||||
icon: category.icon,
|
||||
idx: index,
|
||||
done,
|
||||
candidateCount: category.candidates.length,
|
||||
onClick: () => setCat(index),
|
||||
rowStyle: baseRow + (active ? 'background:#f1ecfb;border-color:#d8c9f2;color:#5f44ad;' : 'border-color:transparent;color:#6f6685;'),
|
||||
iconStyle: "flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;" + (active ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#efe7fb;color:#9a7fce;'),
|
||||
|
||||
@@ -37,6 +37,7 @@ export function useHomeLandingState() {
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
bootstrapArchiveYears,
|
||||
formatRange,
|
||||
@@ -137,7 +138,7 @@ export function useHomeLandingState() {
|
||||
sectionActionStyle,
|
||||
} = useHomePhasePresentation({
|
||||
previewPhase,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
@@ -263,6 +264,7 @@ export function useHomeLandingState() {
|
||||
privacyContentHtml,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
|
||||
@@ -38,13 +38,31 @@ export function useHomeParticipationPresentation(params: {
|
||||
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
|
||||
const savedVoteActive = computed(() => savedVoteCount.value > 0)
|
||||
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
|
||||
const activeCatName = computed(() => activeCategory.value?.name ?? '')
|
||||
const activeCatName = computed(() => {
|
||||
const category = activeCategory.value
|
||||
return category ? `${category.groupName} · ${category.name}` : ''
|
||||
})
|
||||
const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt'))
|
||||
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
|
||||
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle in jeder Hauptkategorie die passende Unterkategorie und dort deine:n Favorit:in. Eine Stimme pro Unterkategorie.'))
|
||||
const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩')
|
||||
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Deine Links wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : savedVoteActive.value ? 'Danke! Deine geänderte Auswahl wurde gespeichert.' : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
|
||||
const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` })))
|
||||
const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` })))
|
||||
const catOptions = computed(() => {
|
||||
const seenGroups = new Set<string>()
|
||||
return displayCategories.value.flatMap((category, index) => {
|
||||
const groupKey = category.groupName.trim().toLowerCase()
|
||||
if (seenGroups.has(groupKey)) return []
|
||||
seenGroups.add(groupKey)
|
||||
return [{
|
||||
id: index,
|
||||
label: `${category.icon} ${category.groupName}`,
|
||||
name: category.groupName,
|
||||
groupName: category.groupName,
|
||||
icon: category.icon,
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
}]
|
||||
})
|
||||
})
|
||||
const canSubmitClip = computed(() => clipSubmissionsEnabled.value && previewPhase.value === 'nomination' && clipDsgvo.value)
|
||||
const clipSubmitStyle = computed(() => canSubmitClip.value ? "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" : "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import { watchAdminErrorToast } from '../../composables/useAdminToast'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
|
||||
@@ -47,6 +48,7 @@ export function useHomeParticipationState(params: {
|
||||
const submitted = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formError = ref('')
|
||||
watchAdminErrorToast(formError)
|
||||
const successKind = ref<null | HomeSuccessKind>(null)
|
||||
const clipCatIdx = ref(0)
|
||||
const clipDsgvo = ref(false)
|
||||
|
||||
@@ -139,8 +139,9 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
return
|
||||
}
|
||||
|
||||
if (entries.some((entry) => entry.streamUrls.length > 3)) {
|
||||
formError.value = 'Pro Kategorie sind maximal drei Links erlaubt.'
|
||||
const overLimitEntry = entries.find((entry) => entry.streamUrls.length > nominationLinkLimitFor(displayCategories.value[entry.categoryIndex]))
|
||||
if (overLimitEntry) {
|
||||
formError.value = `Pro Kategorie sind maximal ${nominationLinkLimitFor(displayCategories.value[overLimitEntry.categoryIndex])} Links erlaubt.`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -171,7 +172,7 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
const category = displayCategories.value[entry.categoryIndex]
|
||||
return store.submitNomination({
|
||||
year: store.overview.year,
|
||||
categoryId: Number(category.id),
|
||||
categoryGroupName: category.groupName,
|
||||
twitchUserId: session.twitchUserId,
|
||||
nominations: entry.streamUrls.map((streamUrl) => ({ streamUrl })),
|
||||
})
|
||||
@@ -260,6 +261,12 @@ function resolveSelectedCreator(category: HomeDisplayCategory | undefined, query
|
||||
?? null
|
||||
}
|
||||
|
||||
function nominationLinkLimitFor(category: HomeDisplayCategory | undefined) {
|
||||
const rawLimit = category?.maxNomineesPerUser
|
||||
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) ? rawLimit : 3
|
||||
return Math.max(1, Math.min(10, Math.round(limit)))
|
||||
}
|
||||
|
||||
function normalizeSearchValue(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed, onBeforeUnmount, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import type { HomeDisplayCategory, HomePreviewPhase } from './homeLandingTypes'
|
||||
import type { HomeLandingCategory, HomePreviewPhase } from './homeLandingTypes'
|
||||
|
||||
export function useHomePhasePresentation(params: {
|
||||
previewPhase: Ref<HomePreviewPhase>
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||
landingCategories: ComputedRef<HomeLandingCategory[]>
|
||||
candidateCount: ComputedRef<number>
|
||||
publicStreamUrl: ComputedRef<string>
|
||||
showDate: ComputedRef<string>
|
||||
@@ -15,7 +15,7 @@ export function useHomePhasePresentation(params: {
|
||||
}) {
|
||||
const {
|
||||
previewPhase,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
@@ -40,7 +40,7 @@ export function useHomePhasePresentation(params: {
|
||||
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
|
||||
const streamLocked = computed(() => !streamLive.value)
|
||||
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : preparationPhase.value ? 'Aufbereitung bis zur Show' : 'Award Show Live')
|
||||
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
|
||||
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase laeuft gerade. Reiche pro Kategorie die erlaubte Anzahl an Stream- oder Kanal-Links ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme fuer deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente fuer die Show vor.' : 'Die Award-Show laeuft jetzt live. Zeit fuer Buehne, Gewinner:innen und ganz viel Glitzer.')
|
||||
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : preparationPhase.value ? `Aufbereitungszeit · ${formatRange('preparation')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
|
||||
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : preparationPhase.value ? 'IN AUFBEREITUNG' : 'AKTIV')
|
||||
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : preparationPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
|
||||
@@ -56,11 +56,11 @@ export function useHomePhasePresentation(params: {
|
||||
const streamLockedTitle = computed(() => completedPhase.value ? 'Die Award-Show ist beendet' : 'Verfügbar, sobald die Show startet')
|
||||
const statOneValue = computed(() => completedPhase.value ? 'DONE' : streamLive.value ? 'LIVE' : String(candidateCount.value))
|
||||
const statOneLabel = computed(() => completedPhase.value ? 'Jahr-Status' : showPhase.value ? 'Show-Status' : 'Kandidat:innen')
|
||||
const statTwoValue = computed(() => String(displayCategories.value.length))
|
||||
const statTwoValue = computed(() => String(landingCategories.value.length))
|
||||
const statThreeValue = computed(() => currentYear.value || '—')
|
||||
const statThreeLabel = computed(() => 'Award-Jahr')
|
||||
const categoryIntroText = computed(() => {
|
||||
const count = displayCategories.value.length
|
||||
const count = landingCategories.value.length
|
||||
const countLabel = count === 1 ? 'Eine Kategorie' : `${count || 'Alle'} Kategorien`
|
||||
return `${countLabel}, eine Show, eine Szene. Stimm für deine Lieblings-VTuber ab und sei live dabei, wenn die Gewinner feststehen.`
|
||||
})
|
||||
|
||||
@@ -27,7 +27,9 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
|
||||
)
|
||||
|
||||
const footerLinks = computed(() =>
|
||||
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (isSafePublicUrl(link.url) || link.content)),
|
||||
(siteContent.value.footerLinks ?? []).filter((link) =>
|
||||
link?.label && (link.key === 'showacts' || isSafePublicUrl(link.url) || link.content),
|
||||
),
|
||||
)
|
||||
|
||||
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
|
||||
|
||||
@@ -35,29 +35,29 @@
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="open" :id="listboxId" class="native-select__menu" :style="menuStyle" role="listbox">
|
||||
<template v-for="(option, index) in options" :key="optionKey(option.value)">
|
||||
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
|
||||
{{ option.group }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="native-select__option"
|
||||
:class="{
|
||||
'native-select__option--active': index === activeIndex,
|
||||
'native-select__option--selected': isSelected(option),
|
||||
}"
|
||||
:disabled="option.disabled"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
@mouseenter="setActiveIndex(index)"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="native-select__option-label">{{ option.label }}</span>
|
||||
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="open" ref="menuEl" :id="listboxId" class="native-select__menu" :style="menuStyle" role="listbox">
|
||||
<template v-for="(option, index) in options" :key="optionKey(option.value)">
|
||||
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
|
||||
{{ option.group }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="native-select__option"
|
||||
:class="{
|
||||
'native-select__option--active': index === activeIndex,
|
||||
'native-select__option--selected': isSelected(option),
|
||||
}"
|
||||
:disabled="option.disabled"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
@mouseenter="setActiveIndex(index)"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="native-select__option-label">{{ option.label }}</span>
|
||||
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,6 +92,7 @@ const emit = defineEmits<{
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const triggerEl = ref<HTMLButtonElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
const listboxId = `native-select-${Math.random().toString(36).slice(2)}`
|
||||
@@ -240,7 +241,8 @@ function onButtonKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!rootEl.value?.contains(event.target as Node)) {
|
||||
const target = event.target as Node
|
||||
if (!rootEl.value?.contains(target) && !menuEl.value?.contains(target)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { onBeforeUnmount, watch, type ComputedRef } from 'vue'
|
||||
|
||||
import { useAdminToast } from './useAdminToast'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
|
||||
const ADMIN_API_DISCONNECT_TOAST_KEY = 'admin-api-disconnected'
|
||||
const ADMIN_API_DISCONNECT_MESSAGE =
|
||||
'Backend/API nicht erreichbar. Der Admin-Bereich bleibt eingeschränkt, bis die Verbindung wieder da ist.'
|
||||
const RECONNECT_PROBE_INTERVAL_MS = 5000
|
||||
|
||||
export function useAdminApiDisconnectToast(isAdminRoute: ComputedRef<boolean>) {
|
||||
const awardsStore = useAwardsStore()
|
||||
const { dismissAdminToast, showAdminToast } = useAdminToast()
|
||||
|
||||
let reconnectTimer: number | undefined
|
||||
let reconnectProbeRunning = false
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer === undefined || typeof window === 'undefined') return
|
||||
window.clearTimeout(reconnectTimer)
|
||||
reconnectTimer = undefined
|
||||
}
|
||||
|
||||
function scheduleReconnectProbe() {
|
||||
if (reconnectTimer !== undefined || typeof window === 'undefined') return
|
||||
|
||||
reconnectTimer = window.setTimeout(async () => {
|
||||
reconnectTimer = undefined
|
||||
if (!isAdminRoute.value || awardsStore.apiMode !== 'fallback' || reconnectProbeRunning) return
|
||||
|
||||
reconnectProbeRunning = true
|
||||
try {
|
||||
await awardsStore.loadDatabaseHealth()
|
||||
} catch {
|
||||
// Keep the persistent disconnect toast visible until health checks pass again.
|
||||
} finally {
|
||||
reconnectProbeRunning = false
|
||||
if (isAdminRoute.value && awardsStore.apiMode === 'fallback') {
|
||||
scheduleReconnectProbe()
|
||||
}
|
||||
}
|
||||
}, RECONNECT_PROBE_INTERVAL_MS)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isAdminRoute.value && awardsStore.apiMode === 'fallback',
|
||||
(shouldShowDisconnectToast) => {
|
||||
if (shouldShowDisconnectToast) {
|
||||
showAdminToast(ADMIN_API_DISCONNECT_MESSAGE, 'error', {
|
||||
key: ADMIN_API_DISCONNECT_TOAST_KEY,
|
||||
persistent: true,
|
||||
})
|
||||
scheduleReconnectProbe()
|
||||
return
|
||||
}
|
||||
|
||||
clearReconnectTimer()
|
||||
dismissAdminToast(ADMIN_API_DISCONNECT_TOAST_KEY)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearReconnectTimer()
|
||||
})
|
||||
}
|
||||
@@ -2,10 +2,18 @@ import { computed, ref, watch, type Ref } from 'vue'
|
||||
|
||||
export type AdminToastTone = 'success' | 'error' | 'info'
|
||||
|
||||
export interface AdminToastOptions {
|
||||
durationMs?: number
|
||||
persistent?: boolean
|
||||
key?: string
|
||||
}
|
||||
|
||||
export interface AdminToast {
|
||||
id: number
|
||||
message: string
|
||||
tone: AdminToastTone
|
||||
persistent?: boolean
|
||||
key?: string
|
||||
}
|
||||
|
||||
const activeToast = ref<AdminToast | null>(null)
|
||||
@@ -13,8 +21,9 @@ let toastId = 0
|
||||
let hideTimer: number | undefined
|
||||
|
||||
export function useAdminToast() {
|
||||
function dismissAdminToast(id?: number) {
|
||||
if (id && activeToast.value?.id !== id) return
|
||||
function dismissAdminToast(target?: number | string) {
|
||||
if (typeof target === 'number' && activeToast.value?.id !== target) return
|
||||
if (typeof target === 'string' && activeToast.value?.key !== target) return
|
||||
activeToast.value = null
|
||||
|
||||
if (hideTimer !== undefined && typeof window !== 'undefined') {
|
||||
@@ -23,9 +32,31 @@ export function useAdminToast() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAdminToast(message: string, tone: AdminToastTone = 'info', durationMs = tone === 'error' ? 6200 : 4200) {
|
||||
function showAdminToast(
|
||||
message: string,
|
||||
tone: AdminToastTone = 'info',
|
||||
options: number | AdminToastOptions = tone === 'error' ? 6200 : 4200,
|
||||
) {
|
||||
const trimmedMessage = message.trim()
|
||||
if (!trimmedMessage) return
|
||||
const resolvedOptions = typeof options === 'number' ? { durationMs: options } : options
|
||||
const durationMs = resolvedOptions.durationMs ?? (tone === 'error' ? 6200 : 4200)
|
||||
const persistent = resolvedOptions.persistent ?? false
|
||||
const key = resolvedOptions.key?.trim() || undefined
|
||||
|
||||
if (activeToast.value?.persistent && activeToast.value.key !== key) {
|
||||
return activeToast.value.id
|
||||
}
|
||||
|
||||
if (
|
||||
key
|
||||
&& activeToast.value?.key === key
|
||||
&& activeToast.value.message === trimmedMessage
|
||||
&& activeToast.value.tone === tone
|
||||
&& activeToast.value.persistent === persistent
|
||||
) {
|
||||
return activeToast.value.id
|
||||
}
|
||||
|
||||
if (hideTimer !== undefined && typeof window !== 'undefined') {
|
||||
window.clearTimeout(hideTimer)
|
||||
@@ -33,11 +64,13 @@ export function useAdminToast() {
|
||||
}
|
||||
|
||||
const id = ++toastId
|
||||
activeToast.value = { id, message: trimmedMessage, tone }
|
||||
activeToast.value = { id, message: trimmedMessage, tone, persistent, key }
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!persistent && typeof window !== 'undefined') {
|
||||
hideTimer = window.setTimeout(() => dismissAdminToast(id), durationMs)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,5 +10,5 @@ export const api = {
|
||||
...systemApi,
|
||||
}
|
||||
|
||||
export { AUTH_TOKEN_KEY } from './http'
|
||||
export { AUTH_TOKEN_KEY } from './authConstants'
|
||||
export { ApiRequestError } from './http'
|
||||
|
||||
@@ -14,15 +14,19 @@ import type {
|
||||
AdminSiteSettingsResponse,
|
||||
AdminSponsorItem,
|
||||
AdminTeamResponse,
|
||||
AdminTrackingRulesResponse,
|
||||
AdminWorkflowRulesResponse,
|
||||
ApproveNominationPayload,
|
||||
AddNominationLinkBlacklistEntryPayload,
|
||||
BulkResolveRiskFlagsPayload,
|
||||
CreateTeamMemberPayload,
|
||||
CreateSeasonPayload,
|
||||
ReopenRejectedNominationPayload,
|
||||
RejectNominationPayload,
|
||||
ResolveRiskFlagPayload,
|
||||
SetAwardResultPayload,
|
||||
UpdateNominationTrackingReviewPayload,
|
||||
UpdateSeasonSubcategoryTemplatesPayload,
|
||||
TeamMemberPasswordResponse,
|
||||
UpdateClipStatusPayload,
|
||||
UpdateOptionalFeatureSettingsPayload,
|
||||
@@ -32,11 +36,15 @@ import type {
|
||||
UpdateNominationLinkBlacklistPayload,
|
||||
UpdateSeasonPayload,
|
||||
UpdateSiteSettingsPayload,
|
||||
UpdateTrackingReviewNotesPayload,
|
||||
UpdateTrackingRulesPayload,
|
||||
UpdateTrackingSourcePayload,
|
||||
UpdateTeamMemberPayload,
|
||||
UpdateTeamRolesPayload,
|
||||
UpdateWorkflowRulesPayload,
|
||||
UpsertCandidatePayload,
|
||||
UpsertCategoryPayload,
|
||||
UpsertCategoryGroupPayload,
|
||||
UpsertSponsorPayload,
|
||||
} from '../../types/awards'
|
||||
import { requestJson } from '../http'
|
||||
@@ -93,9 +101,16 @@ export const adminApi = {
|
||||
getAdminRiskRules: () => requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules'),
|
||||
updateAdminRiskRules: (payload: UpdateRiskRulesPayload) =>
|
||||
requestJson<AdminRiskRulesResponse>('/api/admin/risk-rules', jsonRequest('PUT', payload)),
|
||||
getAdminWorkflowRules: () => requestJson<AdminWorkflowRulesResponse>('/api/admin/workflow-rules'),
|
||||
updateAdminWorkflowRules: (payload: UpdateWorkflowRulesPayload) =>
|
||||
requestJson<AdminWorkflowRulesResponse>('/api/admin/workflow-rules', jsonRequest('PUT', payload)),
|
||||
getAdminWorkflowRules: (seasonId: number) => requestJson<AdminWorkflowRulesResponse>(`/api/admin/seasons/${seasonId}/workflow-rules`),
|
||||
updateAdminWorkflowRules: (seasonId: number, payload: UpdateWorkflowRulesPayload) =>
|
||||
requestJson<AdminWorkflowRulesResponse>(`/api/admin/seasons/${seasonId}/workflow-rules`, jsonRequest('PUT', payload)),
|
||||
getAdminTrackingRules: () => requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules'),
|
||||
updateAdminTrackingRules: (payload: UpdateTrackingRulesPayload) =>
|
||||
requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules', jsonRequest('PUT', payload)),
|
||||
updateAdminTrackingSource: (payload: UpdateTrackingSourcePayload) =>
|
||||
requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules/source', jsonRequest('PUT', payload)),
|
||||
updateAdminTrackingReviewNotes: (payload: UpdateTrackingReviewNotesPayload) =>
|
||||
requestJson<AdminTrackingRulesResponse>('/api/admin/tracking-rules/notes', jsonRequest('PUT', payload)),
|
||||
getAdminSeasons: () => requestJson<AdminSeasonListItem[]>('/api/admin/seasons'),
|
||||
getAdminSeasonDetail: (seasonId: number) =>
|
||||
requestJson<AdminSeasonDetailResponse>(`/api/admin/seasons/${seasonId}`),
|
||||
@@ -157,6 +172,26 @@ export const adminApi = {
|
||||
requestJson<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateAdminSeasonSubcategoryTemplates: (seasonId: number, payload: UpdateSeasonSubcategoryTemplatesPayload) =>
|
||||
requestJson<{ saved: boolean; seasonId: number; templateCount: number }>(
|
||||
`/api/admin/seasons/${seasonId}/subcategory-templates`,
|
||||
jsonRequest('PUT', payload),
|
||||
),
|
||||
createAdminCategoryGroup: (seasonId: number, payload: UpsertCategoryGroupPayload) =>
|
||||
requestJson<{ saved: boolean; seasonId: number; groupName: string }>(
|
||||
`/api/admin/seasons/${seasonId}/category-groups`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
updateAdminCategoryGroup: (seasonId: number, groupName: string, payload: UpsertCategoryGroupPayload) =>
|
||||
requestJson<{ saved: boolean; seasonId: number; groupName: string }>(
|
||||
`/api/admin/seasons/${seasonId}/category-groups/${encodeURIComponent(groupName)}`,
|
||||
jsonRequest('PUT', payload),
|
||||
),
|
||||
deleteAdminCategoryGroup: (seasonId: number, groupName: string) =>
|
||||
requestJson<{ deleted: boolean; seasonId: number; groupName: string }>(
|
||||
`/api/admin/seasons/${seasonId}/category-groups/${encodeURIComponent(groupName)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) =>
|
||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
|
||||
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
|
||||
@@ -190,6 +225,16 @@ export const adminApi = {
|
||||
`/api/admin/nominations/${nominationId}/reject`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
reopenRejectedAdminNomination: (nominationId: number, payload: ReopenRejectedNominationPayload = {}) =>
|
||||
requestJson<{ saved: boolean; nominationId: number; reopened: boolean }>(
|
||||
`/api/admin/nominations/${nominationId}/reopen`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
updateNominationTrackingReview: (nominationId: number, payload: UpdateNominationTrackingReviewPayload) =>
|
||||
requestJson<{ saved: boolean; nominationId: number; status: string }>(
|
||||
`/api/admin/nominations/${nominationId}/tracking-review`,
|
||||
jsonRequest('POST', payload),
|
||||
),
|
||||
getAdminNominationLinkBlacklist: () =>
|
||||
requestJson<AdminNominationLinkBlacklistResponse>('/api/admin/nominations/link-blacklist'),
|
||||
updateAdminNominationLinkBlacklist: (payload: UpdateNominationLinkBlacklistPayload) =>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AuthStore } from '../stores/auth'
|
||||
import { AUTH_TOKEN_KEY } from './authConstants'
|
||||
|
||||
const AUTH_ACTIVITY_KEY = 'vtsa-last-activity-at'
|
||||
const AUTH_CLEARED_EVENT = 'vtsa-auth-cleared'
|
||||
const ACTIVITY_PERSIST_INTERVAL_MS = 30_000
|
||||
const IDLE_CHECK_INTERVAL_MS = 60_000
|
||||
|
||||
export function notifyAuthCleared() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new Event(AUTH_CLEARED_EVENT))
|
||||
}
|
||||
|
||||
export function installAuthSessionManager(authStore: AuthStore) {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
let lastActivityAt = readLastActivityAt()
|
||||
let lastPersistedAt = 0
|
||||
|
||||
const syncActivity = (timestamp = Date.now(), forcePersist = false) => {
|
||||
lastActivityAt = timestamp
|
||||
|
||||
if (!authStore.isLoggedIn) {
|
||||
return
|
||||
}
|
||||
|
||||
if (forcePersist || timestamp - lastPersistedAt >= ACTIVITY_PERSIST_INTERVAL_MS) {
|
||||
window.localStorage.setItem(AUTH_ACTIVITY_KEY, String(timestamp))
|
||||
lastPersistedAt = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
const clearSessionLocally = () => {
|
||||
authStore.clearSession()
|
||||
}
|
||||
|
||||
const checkIdleTimeout = async () => {
|
||||
const timeoutHours = authStore.session?.sessionIdleTimeoutHours
|
||||
if (!authStore.isLoggedIn || !timeoutHours) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() - lastActivityAt < timeoutHours * 60 * 60 * 1000) {
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
}
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === AUTH_ACTIVITY_KEY && event.newValue) {
|
||||
const timestamp = Number(event.newValue)
|
||||
if (Number.isFinite(timestamp) && timestamp > lastActivityAt) {
|
||||
lastActivityAt = timestamp
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === AUTH_TOKEN_KEY && !event.newValue) {
|
||||
clearSessionLocally()
|
||||
}
|
||||
}
|
||||
|
||||
const onAuthCleared = () => {
|
||||
clearSessionLocally()
|
||||
}
|
||||
|
||||
const activityListener = () => {
|
||||
syncActivity()
|
||||
}
|
||||
|
||||
const activityEvents = ['pointerdown', 'keydown', 'scroll', 'touchstart']
|
||||
activityEvents.forEach((eventName) => {
|
||||
window.addEventListener(eventName, activityListener, { passive: true })
|
||||
})
|
||||
window.addEventListener('storage', onStorage)
|
||||
window.addEventListener(AUTH_CLEARED_EVENT, onAuthCleared)
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void checkIdleTimeout()
|
||||
}, IDLE_CHECK_INTERVAL_MS)
|
||||
|
||||
syncActivity(Date.now(), true)
|
||||
|
||||
return () => {
|
||||
activityEvents.forEach((eventName) => {
|
||||
window.removeEventListener(eventName, activityListener)
|
||||
})
|
||||
window.removeEventListener('storage', onStorage)
|
||||
window.removeEventListener(AUTH_CLEARED_EVENT, onAuthCleared)
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
function readLastActivityAt() {
|
||||
if (typeof window === 'undefined') {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
const storedValue = Number(window.localStorage.getItem(AUTH_ACTIVITY_KEY))
|
||||
return Number.isFinite(storedValue) && storedValue > 0
|
||||
? storedValue
|
||||
: Date.now()
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { notifyAuthCleared } from './authSessionManager'
|
||||
import { AUTH_TOKEN_KEY } from './authConstants'
|
||||
|
||||
const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '')
|
||||
const API_LABEL = API_URL || 'same-origin /api'
|
||||
export const AUTH_TOKEN_KEY = 'vtsa-session-token'
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
status: number | null
|
||||
@@ -20,6 +22,7 @@ function getAuthToken() {
|
||||
function clearAuthToken() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
notifyAuthCleared()
|
||||
}
|
||||
|
||||
async function parseError(response: Response, path: string) {
|
||||
|
||||
@@ -6,6 +6,15 @@ export interface ReleaseNoteItem {
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ReleaseVersionEntry {
|
||||
version: string
|
||||
buildVersion?: string
|
||||
buildDateLabel: string
|
||||
releaseType: ReleaseChangeType
|
||||
summary: string
|
||||
notes: ReleaseNoteItem[]
|
||||
}
|
||||
|
||||
export interface VersionRule {
|
||||
pattern: string
|
||||
label: string
|
||||
@@ -34,55 +43,66 @@ export const versionRules: VersionRule[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const releaseNotes: ReleaseNoteItem[] = [
|
||||
export const releaseVersions: ReleaseVersionEntry[] = [
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Twitch Login ist live',
|
||||
description: 'Login und Account-Verknuepfung laufen jetzt ueber den offiziellen Twitch OAuth Flow.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Team-Accounts koennen Twitch verbinden',
|
||||
description: 'Teammitglieder koennen ihren Account mit Twitch verknuepfen, wieder loesen und danach sauber ueber Twitch einsteigen.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Team-Status aktualisiert sich automatisch',
|
||||
description: 'In der Teamverwaltung siehst du regelmaessig, wer online ist. Die Ansicht aktualisiert sich alle 30 Sekunden.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Moderation ist schneller bedienbar',
|
||||
description: 'Reviewed Nominierungen und Risiko-Queues lassen sich angenehmer mit Pfeiltasten und Tastatur bedienen.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Admin-Bereiche wurden aufgeraeumt',
|
||||
description: 'Jahre, Landingpage, Risiko, Team und Analytics sind kompakter, klarer gruppiert und weniger erschlagend.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'User-Profil wurde repariert und ueberarbeitet',
|
||||
description: 'Das Profil ist wieder ein echter Teil des Flows, inklusive Twitch-Verknuepfung und sauberem Account-Status.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Besseres Feedback beim Bearbeiten',
|
||||
description: 'Buttons haben Animationen, Speichern/Bearbeiten gibt sichtbarere Rueckmeldung und Modals fuehlen sich stabiler an.',
|
||||
},
|
||||
{
|
||||
type: 'fix',
|
||||
title: 'Dropdowns sehen wieder korrekt aus',
|
||||
description: 'Das Dropdown-Design wurde stabilisiert und passt nun besser zum restlichen Admin-UI.',
|
||||
},
|
||||
{
|
||||
type: 'fix',
|
||||
title: 'Backend, Auth und Sicherheit wurden gehaertet',
|
||||
description: 'Sessions, Team-Rechte, Audit-Logs, Public Writes, Vote-Eindeutigkeit und Runtime-Settings wurden robuster gemacht.',
|
||||
},
|
||||
{
|
||||
type: 'fix',
|
||||
title: 'Datenbank- und Deploy-Sicherheit verbessert',
|
||||
description: 'Healthchecks, Migrationen, Seed-Verhalten und produktionsnahe Konfiguration sind klarer sichtbar und weniger fehleranfaellig.',
|
||||
version: semanticVersion,
|
||||
buildVersion,
|
||||
buildDateLabel,
|
||||
releaseType: 'feature',
|
||||
summary: 'Feature-Release mit Twitch Login, Team-Verknuepfung, Admin-UX-Polish und Backend-Haertung.',
|
||||
notes: [
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Twitch Login ist live',
|
||||
description: 'Login und Account-Verknuepfung laufen jetzt ueber den offiziellen Twitch OAuth Flow.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Team-Accounts koennen Twitch verbinden',
|
||||
description: 'Teammitglieder koennen ihren Account mit Twitch verknuepfen, wieder loesen und danach sauber ueber Twitch einsteigen.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Team-Status aktualisiert sich automatisch',
|
||||
description: 'In der Teamverwaltung siehst du regelmaessig, wer online ist. Die Ansicht aktualisiert sich alle 30 Sekunden.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Moderation ist schneller bedienbar',
|
||||
description: 'Reviewed Nominierungen und Risiko-Queues lassen sich angenehmer mit Pfeiltasten und Tastatur bedienen.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Admin-Bereiche wurden aufgeraeumt',
|
||||
description: 'Jahre, Landingpage, Risiko, Team und Analytics sind kompakter, klarer gruppiert und weniger erschlagend.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'User-Profil wurde repariert und ueberarbeitet',
|
||||
description: 'Das Profil ist wieder ein echter Teil des Flows, inklusive Twitch-Verknuepfung und sauberem Account-Status.',
|
||||
},
|
||||
{
|
||||
type: 'feature',
|
||||
title: 'Besseres Feedback beim Bearbeiten',
|
||||
description: 'Buttons haben Animationen, Speichern/Bearbeiten gibt sichtbarere Rueckmeldung und Modals fuehlen sich stabiler an.',
|
||||
},
|
||||
{
|
||||
type: 'fix',
|
||||
title: 'Dropdowns sehen wieder korrekt aus',
|
||||
description: 'Das Dropdown-Design wurde stabilisiert und passt nun besser zum restlichen Admin-UI.',
|
||||
},
|
||||
{
|
||||
type: 'fix',
|
||||
title: 'Backend, Auth und Sicherheit wurden gehaertet',
|
||||
description: 'Sessions, Team-Rechte, Audit-Logs, Public Writes, Vote-Eindeutigkeit und Runtime-Settings wurden robuster gemacht.',
|
||||
},
|
||||
{
|
||||
type: 'fix',
|
||||
title: 'Datenbank- und Deploy-Sicherheit verbessert',
|
||||
description: 'Healthchecks, Migrationen, Seed-Verhalten und produktionsnahe Konfiguration sind klarer sichtbar und weniger fehleranfaellig.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const releaseNotes: ReleaseNoteItem[] = releaseVersions[0]?.notes ?? []
|
||||
|
||||
@@ -5,6 +5,7 @@ import Aura from '@primeuix/themes/aura'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { installAuthSessionManager } from './lib/authSessionManager'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import './style.css'
|
||||
|
||||
@@ -25,5 +26,6 @@ app.use(PrimeVue, {
|
||||
|
||||
const authStore = useAuthStore(pinia)
|
||||
void authStore.hydrate()
|
||||
installAuthSessionManager(authStore)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -19,6 +19,9 @@ const adminRoutePermissions: Record<string, string> = {
|
||||
'admin-winners': 'winners',
|
||||
'admin-content': 'content',
|
||||
'admin-settings': 'settings',
|
||||
'admin-settings-access': 'settings',
|
||||
'admin-settings-workflows': 'settings',
|
||||
'admin-tracking-rules': 'settings',
|
||||
}
|
||||
|
||||
function firstAllowedAdminRoute(permissionKeys: string[] = []) {
|
||||
|
||||
@@ -33,12 +33,6 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/admin/AdminCategoriesView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'categories_v2',
|
||||
name: 'admin-categories-v2',
|
||||
component: () => import('../views/admin/AdminCategoriesViewV2.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'candidates',
|
||||
name: 'admin-candidates',
|
||||
@@ -101,6 +95,40 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/admin/AdminSettingsView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'settings/access',
|
||||
name: 'admin-settings-access',
|
||||
component: () => import('../views/admin/AdminAccessSettingsView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'settings/demo-preview',
|
||||
redirect: { name: 'admin-settings-access' },
|
||||
},
|
||||
{
|
||||
path: 'settings/maintenance-mode',
|
||||
redirect: { name: 'admin-settings-access' },
|
||||
},
|
||||
{
|
||||
path: 'settings/workflows',
|
||||
name: 'admin-settings-workflows',
|
||||
component: () => import('../views/admin/AdminWorkflowSettingsView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'settings/workflow-rules',
|
||||
redirect: { name: 'admin-settings-workflows' },
|
||||
},
|
||||
{
|
||||
path: 'settings/optional-workflows',
|
||||
redirect: { name: 'admin-settings-workflows' },
|
||||
},
|
||||
{
|
||||
path: 'tracking-rules',
|
||||
name: 'admin-tracking-rules',
|
||||
component: () => import('../views/admin/AdminTrackingRulesView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
]
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
|
||||
@@ -63,8 +63,13 @@ export const useAuthStore = defineStore('auth', {
|
||||
isTeamSession: (state) => Boolean(state.session?.teamLogin),
|
||||
},
|
||||
actions: {
|
||||
clearSession() {
|
||||
this.session = null
|
||||
writeStoredToken(null)
|
||||
},
|
||||
async hydrate() {
|
||||
if (!readStoredToken()) {
|
||||
this.session = null
|
||||
this.hydrated = true
|
||||
return
|
||||
}
|
||||
@@ -72,9 +77,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
try {
|
||||
this.session = await api.getSession()
|
||||
} catch (error) {
|
||||
this.session = null
|
||||
if (error instanceof ApiRequestError && (error.status === 401 || error.status === 403)) {
|
||||
writeStoredToken(null)
|
||||
this.clearSession()
|
||||
}
|
||||
} finally {
|
||||
this.hydrated = true
|
||||
@@ -162,8 +166,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
try {
|
||||
await api.logout()
|
||||
} finally {
|
||||
this.session = null
|
||||
writeStoredToken(null)
|
||||
this.clearSession()
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
@@ -171,8 +174,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.loading = true
|
||||
try {
|
||||
const result = await api.deleteMyData()
|
||||
this.session = null
|
||||
writeStoredToken(null)
|
||||
this.clearSession()
|
||||
return result
|
||||
} finally {
|
||||
this.loading = false
|
||||
|
||||
+111
-12
@@ -9,6 +9,7 @@ import {
|
||||
createEmptyAdminSeasonDetail,
|
||||
createEmptyAdminOptionalFeatureSettings,
|
||||
createEmptyAdminSiteSettings,
|
||||
createEmptyAdminTrackingRulesResponse,
|
||||
createEmptyAdminWorkflowRulesResponse,
|
||||
createEmptyArchive,
|
||||
createEmptyDatabaseHealth,
|
||||
@@ -23,20 +24,27 @@ import type {
|
||||
CreateNominationPayload,
|
||||
CreateShowactApplicationPayload,
|
||||
CreateVotePayload,
|
||||
ReopenRejectedNominationPayload,
|
||||
RejectNominationPayload,
|
||||
ResolveRiskFlagPayload,
|
||||
SetAwardResultPayload,
|
||||
AdminAuditQueryOptions,
|
||||
AdminRiskQueryOptions,
|
||||
UpdateSeasonSubcategoryTemplatesPayload,
|
||||
UpdateSeasonPayload,
|
||||
UpdateClipStatusPayload,
|
||||
UpdateNominationLinkBlacklistPayload,
|
||||
UpdateOptionalFeatureSettingsPayload,
|
||||
UpdateNominationTrackingReviewPayload,
|
||||
UpdateShowactStatusPayload,
|
||||
UpdateSiteSettingsPayload,
|
||||
UpdateTrackingReviewNotesPayload,
|
||||
UpdateTrackingRulesPayload,
|
||||
UpdateTrackingSourcePayload,
|
||||
UpdateWorkflowRulesPayload,
|
||||
UpsertCandidatePayload,
|
||||
UpsertCategoryPayload,
|
||||
UpsertCategoryGroupPayload,
|
||||
UpsertSponsorPayload,
|
||||
} from '../types/awards'
|
||||
|
||||
@@ -57,7 +65,9 @@ export const useAwardsStore = defineStore('awards', {
|
||||
this.categories = await api.getSeasonCategories(this.overview.year)
|
||||
await this.loadPublicSponsors(this.overview.year)
|
||||
const overviewArchiveYears = this.overview.archiveYears ?? []
|
||||
const initialArchiveYear = overviewArchiveYears[0]?.year
|
||||
const initialArchiveYear = canExposeCurrentSeasonWinners(this.overview.currentPhase)
|
||||
? this.overview.year
|
||||
: overviewArchiveYears[0]?.year
|
||||
?? this.overview.winnersPreview[0]?.year
|
||||
?? this.overview.year - 1
|
||||
this.archive = await api.getWinnerArchive(initialArchiveYear)
|
||||
@@ -92,12 +102,11 @@ export const useAwardsStore = defineStore('awards', {
|
||||
},
|
||||
async loadAdmin() {
|
||||
try {
|
||||
const [admin, adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, adminWorkflowRules, databaseHealth] = await Promise.all([
|
||||
const [admin, adminSeasons, adminSiteSettings, adminOptionalFeatureSettings, databaseHealth] = await Promise.all([
|
||||
api.getAdminDashboard(),
|
||||
api.getAdminSeasons(),
|
||||
api.getAdminSiteSettings(),
|
||||
api.getAdminOptionalFeatureSettings(),
|
||||
api.getAdminWorkflowRules(),
|
||||
api.getDatabaseHealth(),
|
||||
])
|
||||
|
||||
@@ -105,7 +114,6 @@ export const useAwardsStore = defineStore('awards', {
|
||||
this.adminSeasons = adminSeasons
|
||||
this.adminSiteSettings = adminSiteSettings
|
||||
this.adminOptionalFeatureSettings = adminOptionalFeatureSettings
|
||||
this.adminWorkflowRules = adminWorkflowRules
|
||||
this.databaseHealth = databaseHealth
|
||||
|
||||
if (!this.adminSelectedSeasonId || !this.adminSeasons.some((season) => season.id === this.adminSelectedSeasonId)) {
|
||||
@@ -113,26 +121,32 @@ export const useAwardsStore = defineStore('awards', {
|
||||
}
|
||||
|
||||
if (this.adminSelectedSeasonId) {
|
||||
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(this.adminSelectedSeasonId))
|
||||
const [sponsors, showacts] = await Promise.all([
|
||||
const [seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
|
||||
api.getAdminSeasonDetail(this.adminSelectedSeasonId),
|
||||
api.getAdminSponsors(this.adminSelectedSeasonId),
|
||||
api.getAdminShowactApplications(this.adminSelectedSeasonId),
|
||||
api.getAdminWorkflowRules(this.adminSelectedSeasonId),
|
||||
])
|
||||
this.adminSeasonDetail = normalizeSeasonDetail(seasonDetail)
|
||||
this.adminSponsors = sponsors
|
||||
this.adminShowactApplications = showacts
|
||||
this.adminWorkflowRules = adminWorkflowRules
|
||||
} else {
|
||||
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
|
||||
this.adminSponsors = []
|
||||
this.adminShowactApplications = []
|
||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||
}
|
||||
this.apiMode = 'api'
|
||||
} catch {
|
||||
this.apiMode = 'fallback'
|
||||
this.admin = createEmptyAdminDashboard()
|
||||
this.adminSeasons = []
|
||||
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
|
||||
this.adminSiteSettings = createEmptyAdminSiteSettings()
|
||||
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
|
||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
|
||||
this.adminRiskHistory = []
|
||||
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
|
||||
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
|
||||
@@ -155,30 +169,43 @@ export const useAwardsStore = defineStore('awards', {
|
||||
this.databaseHealth = databaseHealth
|
||||
this.apiMode = 'api'
|
||||
} catch {
|
||||
this.apiMode = 'fallback'
|
||||
this.adminSiteSettings = createEmptyAdminSiteSettings()
|
||||
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
|
||||
this.databaseHealth = createEmptyDatabaseHealth()
|
||||
}
|
||||
},
|
||||
async loadDatabaseHealth() {
|
||||
this.databaseHealth = await api.getDatabaseHealth()
|
||||
return this.databaseHealth
|
||||
try {
|
||||
this.databaseHealth = await api.getDatabaseHealth()
|
||||
this.apiMode = 'api'
|
||||
return this.databaseHealth
|
||||
} catch (error) {
|
||||
this.databaseHealth = createEmptyDatabaseHealth()
|
||||
this.apiMode = 'fallback'
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async loadAdminSeasonDetail(seasonId: number) {
|
||||
try {
|
||||
this.adminSelectedSeasonId = seasonId
|
||||
this.adminSeasonDetail = normalizeSeasonDetail(await api.getAdminSeasonDetail(seasonId))
|
||||
const [sponsors, showacts] = await Promise.all([
|
||||
const [seasonDetail, sponsors, showacts, adminWorkflowRules] = await Promise.all([
|
||||
api.getAdminSeasonDetail(seasonId),
|
||||
api.getAdminSponsors(seasonId),
|
||||
api.getAdminShowactApplications(seasonId),
|
||||
api.getAdminWorkflowRules(seasonId),
|
||||
])
|
||||
this.adminSeasonDetail = normalizeSeasonDetail(seasonDetail)
|
||||
this.adminSponsors = sponsors
|
||||
this.adminShowactApplications = showacts
|
||||
this.adminWorkflowRules = adminWorkflowRules
|
||||
this.apiMode = 'api'
|
||||
} catch {
|
||||
this.apiMode = 'fallback'
|
||||
this.adminSeasonDetail = createEmptyAdminSeasonDetail()
|
||||
this.adminSponsors = []
|
||||
this.adminShowactApplications = []
|
||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||
}
|
||||
},
|
||||
async initializeAdminWorkspace() {
|
||||
@@ -325,6 +352,26 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
|
||||
return result
|
||||
},
|
||||
async updateAdminSeasonSubcategoryTemplates(seasonId: number, payload: UpdateSeasonSubcategoryTemplatesPayload) {
|
||||
const result = await api.updateAdminSeasonSubcategoryTemplates(seasonId, payload)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
|
||||
return result
|
||||
},
|
||||
async createAdminCategoryGroup(seasonId: number, payload: UpsertCategoryGroupPayload) {
|
||||
const result = await api.createAdminCategoryGroup(seasonId, payload)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
|
||||
return result
|
||||
},
|
||||
async updateAdminCategoryGroup(seasonId: number, groupName: string, payload: UpsertCategoryGroupPayload) {
|
||||
const result = await api.updateAdminCategoryGroup(seasonId, groupName, payload)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
|
||||
return result
|
||||
},
|
||||
async deleteAdminCategoryGroup(seasonId: number, groupName: string) {
|
||||
const result = await api.deleteAdminCategoryGroup(seasonId, groupName)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
|
||||
return result
|
||||
},
|
||||
async createAdminCandidate(seasonId: number, payload: UpsertCandidatePayload) {
|
||||
const result = await api.createAdminCandidate(seasonId, payload)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadHome: true })
|
||||
@@ -379,6 +426,11 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
|
||||
return result
|
||||
},
|
||||
async reopenRejectedAdminNomination(nominationId: number, seasonId: number, payload: ReopenRejectedNominationPayload = {}) {
|
||||
const result = await api.reopenRejectedAdminNomination(nominationId, payload)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
|
||||
return result
|
||||
},
|
||||
async bulkRejectAdminNominations(nominationIds: number[], seasonId: number, reviewNote?: string) {
|
||||
await Promise.allSettled(nominationIds.map((id) => api.rejectAdminNomination(id, { reviewNote })))
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
|
||||
@@ -438,13 +490,52 @@ export const useAwardsStore = defineStore('awards', {
|
||||
return this.adminOptionalFeatureSettings
|
||||
},
|
||||
async loadAdminWorkflowRules() {
|
||||
this.adminWorkflowRules = await api.getAdminWorkflowRules()
|
||||
if (!this.adminSelectedSeasonId) {
|
||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||
return this.adminWorkflowRules
|
||||
}
|
||||
|
||||
this.adminWorkflowRules = await api.getAdminWorkflowRules(this.adminSelectedSeasonId)
|
||||
return this.adminWorkflowRules
|
||||
},
|
||||
async updateAdminWorkflowRules(payload: UpdateWorkflowRulesPayload) {
|
||||
this.adminWorkflowRules = await api.updateAdminWorkflowRules(payload)
|
||||
if (!this.adminSelectedSeasonId) {
|
||||
throw new Error('Kein Award-Jahr fuer Workflow-Regeln ausgewaehlt.')
|
||||
}
|
||||
|
||||
this.adminWorkflowRules = await api.updateAdminWorkflowRules(this.adminSelectedSeasonId, payload)
|
||||
return this.adminWorkflowRules
|
||||
},
|
||||
async loadAdminTrackingRules() {
|
||||
this.adminTrackingRules = await api.getAdminTrackingRules()
|
||||
return this.adminTrackingRules
|
||||
},
|
||||
async updateAdminTrackingRules(payload: UpdateTrackingRulesPayload) {
|
||||
this.adminTrackingRules = await api.updateAdminTrackingRules(payload)
|
||||
if (this.adminSelectedSeasonId) {
|
||||
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||
}
|
||||
return this.adminTrackingRules
|
||||
},
|
||||
async updateAdminTrackingSource(payload: UpdateTrackingSourcePayload) {
|
||||
this.adminTrackingRules = await api.updateAdminTrackingSource(payload)
|
||||
if (this.adminSelectedSeasonId) {
|
||||
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||
}
|
||||
return this.adminTrackingRules
|
||||
},
|
||||
async updateAdminTrackingReviewNotes(payload: UpdateTrackingReviewNotesPayload) {
|
||||
this.adminTrackingRules = await api.updateAdminTrackingReviewNotes(payload)
|
||||
if (this.adminSelectedSeasonId) {
|
||||
await this.loadAdminSeasonDetail(this.adminSelectedSeasonId)
|
||||
}
|
||||
return this.adminTrackingRules
|
||||
},
|
||||
async updateNominationTrackingReview(nominationId: number, seasonId: number, payload: UpdateNominationTrackingReviewPayload) {
|
||||
const result = await api.updateNominationTrackingReview(nominationId, payload)
|
||||
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
|
||||
return result
|
||||
},
|
||||
async updateAdminSiteSettings(payload: UpdateSiteSettingsPayload) {
|
||||
const result = await api.updateAdminSiteSettings(payload)
|
||||
this.adminSiteSettings = await api.getAdminSiteSettings()
|
||||
@@ -454,4 +545,12 @@ export const useAwardsStore = defineStore('awards', {
|
||||
},
|
||||
})
|
||||
|
||||
function canExposeCurrentSeasonWinners(currentPhase: string) {
|
||||
const normalized = currentPhase.trim().toLowerCase()
|
||||
return normalized.includes('abgeschlossen')
|
||||
|| normalized.includes('archiv')
|
||||
|| normalized.includes('complete')
|
||||
|| normalized.includes('ended')
|
||||
}
|
||||
|
||||
export type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AdminShowactApplicationItem,
|
||||
AdminSiteSettingsResponse,
|
||||
AdminSponsorItem,
|
||||
AdminTrackingRulesResponse,
|
||||
AdminWorkflowRulesResponse,
|
||||
DatabaseHealthResponse,
|
||||
OverviewResponse,
|
||||
@@ -39,6 +40,8 @@ export function createEmptyOverview(): OverviewResponse {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
newsletterUrl: '',
|
||||
shareXUrl: '',
|
||||
shareDiscordUrl: '',
|
||||
privacyEmail: '',
|
||||
privacyPolicyContent: '',
|
||||
socialLinks: [],
|
||||
@@ -50,8 +53,11 @@ export function createEmptyOverview(): OverviewResponse {
|
||||
clipAdminMenuVisible: true,
|
||||
clipSubmissionDisabledMessage: 'Clip-Einreichungen sind aktuell geschlossen.',
|
||||
showactApplicationsEnabled: false,
|
||||
showactApplicationStartsAt: null,
|
||||
showactApplicationEndsAt: null,
|
||||
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
|
||||
sponsorsVisible: true,
|
||||
showactFormSchemaJson: '[]',
|
||||
},
|
||||
faq: [],
|
||||
}
|
||||
@@ -108,6 +114,22 @@ export function createEmptyAdminWorkflowRulesResponse(): AdminWorkflowRulesRespo
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyAdminTrackingRulesResponse(): AdminTrackingRulesResponse {
|
||||
return {
|
||||
source: {
|
||||
providerKey: 'twitchtracker',
|
||||
providerLabel: 'TwitchTracker Basic API',
|
||||
baseUrl: 'https://twitchtracker.com/api',
|
||||
notesSummary: '',
|
||||
showManualReviewNotesInReview: true,
|
||||
},
|
||||
importantMetrics: [],
|
||||
optionalMetrics: [],
|
||||
flags: [],
|
||||
manualReviewNotes: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
|
||||
return {
|
||||
id: 0,
|
||||
@@ -125,10 +147,14 @@ export function createEmptyAdminSeasonDetail(): AdminSeasonDetailResponse {
|
||||
reviewEndsAt: '',
|
||||
showDate: '',
|
||||
showStartsAt: '20:00:00',
|
||||
subcategoryTemplates: [],
|
||||
categories: [],
|
||||
candidates: [],
|
||||
pendingNominations: [],
|
||||
pendingNominationGroups: [],
|
||||
reviewedNominations: [],
|
||||
trackingReviewNotes: '',
|
||||
showTrackingReviewNotes: true,
|
||||
results: [],
|
||||
clipSubmissions: [],
|
||||
}
|
||||
@@ -139,6 +165,8 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
newsletterUrl: '',
|
||||
shareXUrl: '',
|
||||
shareDiscordUrl: '',
|
||||
privacyEmail: '',
|
||||
privacyPolicyContent: '',
|
||||
privacyPolicyUpdatedBy: null,
|
||||
@@ -153,6 +181,7 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
|
||||
showactsContent: '',
|
||||
socialLinks: [],
|
||||
faq: [],
|
||||
showactFormSchemaJson: '[]',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +192,9 @@ export function createEmptyAdminOptionalFeatureSettings(): AdminOptionalFeatureS
|
||||
clipAdminMenuVisible: true,
|
||||
clipSubmissionDisabledMessage: 'Clip-Einreichungen sind aktuell geschlossen.',
|
||||
showactApplicationsEnabled: false,
|
||||
showactApplicationStartsAt: null,
|
||||
showactApplicationEndsAt: null,
|
||||
showactApplicationsOpenNow: false,
|
||||
showactApplicationDisabledMessage: 'Showact-Bewerbungen sind aktuell geschlossen.',
|
||||
sponsorsVisible: true,
|
||||
}
|
||||
@@ -191,6 +223,7 @@ export function createAwardsState() {
|
||||
adminSiteSettings: createEmptyAdminSiteSettings(),
|
||||
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
|
||||
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
|
||||
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
|
||||
adminSponsors: [] as AdminSponsorItem[],
|
||||
adminShowactApplications: [] as AdminShowactApplicationItem[],
|
||||
adminRiskHistory: [] as AdminRiskFlag[],
|
||||
@@ -212,6 +245,7 @@ export function createAwardsState() {
|
||||
export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminSeasonDetailResponse {
|
||||
return {
|
||||
...detail,
|
||||
subcategoryTemplates: detail.subcategoryTemplates ?? [],
|
||||
categories: detail.categories ?? [],
|
||||
candidates: (detail.candidates ?? []).map((candidate) => ({
|
||||
...candidate,
|
||||
@@ -221,9 +255,13 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
|
||||
clipCompilationTitle: candidate.clipCompilationTitle ?? null,
|
||||
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
|
||||
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
|
||||
streamerIdentityId: candidate.streamerIdentityId ?? null,
|
||||
nominationTally: candidate.nominationTally ?? 0,
|
||||
})),
|
||||
pendingNominations: detail.pendingNominations ?? [],
|
||||
pendingNominationGroups: detail.pendingNominationGroups ?? [],
|
||||
reviewedNominations: detail.reviewedNominations ?? [],
|
||||
trackingReviewNotes: detail.trackingReviewNotes ?? '',
|
||||
results: detail.results ?? [],
|
||||
clipSubmissions: detail.clipSubmissions ?? [],
|
||||
}
|
||||
|
||||
@@ -92,6 +92,83 @@ export interface AdminWorkflowRulesResponse {
|
||||
rules: AdminWorkflowRule[]
|
||||
}
|
||||
|
||||
export interface AdminTrackingSource {
|
||||
providerKey: string
|
||||
providerLabel: string
|
||||
baseUrl: string
|
||||
notesSummary: string
|
||||
showManualReviewNotesInReview: boolean
|
||||
}
|
||||
|
||||
export interface AdminTrackingMetricRule {
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
sourceSupport: 'auto' | 'manual' | 'context_only' | string
|
||||
description: string
|
||||
requiredForAutoClassification: boolean
|
||||
showInReview: boolean
|
||||
showInAdminSummary: boolean
|
||||
manualOverrideAllowed: boolean
|
||||
windowKey: '7d' | '30d' | '90d' | 'all_time' | string
|
||||
autoSupportedWindowKeys: string[]
|
||||
providerFieldKey: string | null
|
||||
topCount: number | null
|
||||
minPrimaryCategorySharePercent: number | null
|
||||
minPrimaryCategoryHours: number | null
|
||||
maxDistinctCategoriesBeforeFlag: number | null
|
||||
ignoredCategories: string[]
|
||||
matchAwardCategoryAgainstTopCategories: boolean
|
||||
flagIfAwardCategoryNotInTopX: boolean
|
||||
flagIfCategorySpreadTooWide: boolean
|
||||
flagIfNoCategoryContextAvailable: boolean
|
||||
minValue: number | null
|
||||
maxValue: number | null
|
||||
}
|
||||
|
||||
export interface AdminTrackingFlagRule {
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
severity: 'high' | 'medium' | 'low' | string
|
||||
description: string
|
||||
autoTriggerEnabled: boolean
|
||||
requiresManualReview: boolean
|
||||
blocksApproval: boolean
|
||||
adminNoteRequiredOnOverride: boolean
|
||||
}
|
||||
|
||||
export interface AdminTrackingFlagHit {
|
||||
key: string
|
||||
label: string
|
||||
severity: string
|
||||
description: string
|
||||
requiresManualReview: boolean
|
||||
blocksApproval: boolean
|
||||
adminNoteRequiredOnOverride: boolean
|
||||
}
|
||||
|
||||
export interface AdminTrackingMetricState {
|
||||
key: string
|
||||
label: string
|
||||
required: boolean
|
||||
sourceSupport: string
|
||||
present: boolean
|
||||
value: string
|
||||
description: string
|
||||
windowKey: string
|
||||
windowLabel: string
|
||||
autoWindowSupported: boolean
|
||||
}
|
||||
|
||||
export interface AdminTrackingRulesResponse {
|
||||
source: AdminTrackingSource
|
||||
importantMetrics: AdminTrackingMetricRule[]
|
||||
optionalMetrics: AdminTrackingMetricRule[]
|
||||
flags: AdminTrackingFlagRule[]
|
||||
manualReviewNotes: string
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
id: number
|
||||
adminTwitchUserId: string
|
||||
@@ -159,15 +236,27 @@ export interface AdminCategoryItem {
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
candidateCount: number
|
||||
}
|
||||
|
||||
export interface AdminSubcategoryTemplate {
|
||||
name: string
|
||||
slug: string
|
||||
sortOrder: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
}
|
||||
|
||||
export interface AdminCandidateItem {
|
||||
id: number
|
||||
categoryId: number
|
||||
streamerIdentityId: number | null
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
nominationTally: number
|
||||
acceptanceStatus: string
|
||||
acceptanceNote: string | null
|
||||
clipCompilationUrl: string | null
|
||||
@@ -181,6 +270,7 @@ export interface AdminAwardResultItem {
|
||||
categoryId: number
|
||||
categoryName: string
|
||||
candidateId: number
|
||||
streamerIdentityId: number | null
|
||||
candidateDisplayName: string
|
||||
candidateChannelSlug: string
|
||||
candidatePlatform: string
|
||||
@@ -188,11 +278,27 @@ export interface AdminAwardResultItem {
|
||||
|
||||
export interface AdminNominationReviewItem {
|
||||
id: number
|
||||
categoryId: number
|
||||
categoryId: number | null
|
||||
categoryGroupName: string
|
||||
categoryName: string
|
||||
submittedByTwitchId: string
|
||||
candidateText: string
|
||||
streamUrl: string | null
|
||||
resolvedChannel: string | null
|
||||
resolvedPlatform: string | null
|
||||
avgViewers: number | null
|
||||
suggestedCategoryId: number | null
|
||||
suggestedCategoryName: string | null
|
||||
streamerIdentityId: number | null
|
||||
trackerStatus: string
|
||||
trackerCheckedAt: string | null
|
||||
trackingReviewStatus: string
|
||||
requiresManualReview: boolean
|
||||
trackingFlags: AdminTrackingFlagHit[]
|
||||
trackingMetrics: AdminTrackingMetricState[]
|
||||
trackingReviewNote: string | null
|
||||
trackingReviewedByTwitchId: string | null
|
||||
trackingReviewedAt: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
candidateId: number | null
|
||||
@@ -202,6 +308,33 @@ export interface AdminNominationReviewItem {
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminNominationReviewGroup {
|
||||
id: number
|
||||
nominationIds: number[]
|
||||
categoryGroupName: string
|
||||
displayName: string
|
||||
streamUrl: string | null
|
||||
resolvedChannel: string | null
|
||||
resolvedPlatform: string | null
|
||||
avgViewers: number | null
|
||||
suggestedCategoryId: number | null
|
||||
suggestedCategoryName: string | null
|
||||
streamerIdentityId: number | null
|
||||
trackerStatus: string
|
||||
trackerCheckedAt: string | null
|
||||
trackingReviewStatus: string
|
||||
requiresManualReview: boolean
|
||||
trackingFlags: AdminTrackingFlagHit[]
|
||||
trackingMetrics: AdminTrackingMetricState[]
|
||||
trackingReviewNote: string | null
|
||||
trackingReviewedByTwitchId: string | null
|
||||
trackingReviewedAt: string | null
|
||||
nominationTally: number
|
||||
uniqueSubmitterCount: number
|
||||
firstSubmittedAt: string
|
||||
lastSubmittedAt: string
|
||||
}
|
||||
|
||||
export interface AdminNominationLinkBlacklistEntry {
|
||||
url: string
|
||||
}
|
||||
@@ -242,10 +375,14 @@ export interface AdminSeasonDetailResponse {
|
||||
reviewEndsAt: string
|
||||
showDate: string
|
||||
showStartsAt: string
|
||||
subcategoryTemplates: AdminSubcategoryTemplate[]
|
||||
categories: AdminCategoryItem[]
|
||||
candidates: AdminCandidateItem[]
|
||||
pendingNominations: AdminNominationReviewItem[]
|
||||
pendingNominationGroups: AdminNominationReviewGroup[]
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
trackingReviewNotes: string
|
||||
showTrackingReviewNotes: boolean
|
||||
results: AdminAwardResultItem[]
|
||||
clipSubmissions: AdminClipSubmissionItem[]
|
||||
}
|
||||
@@ -254,6 +391,8 @@ export interface AdminSiteSettingsResponse {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
privacyEmail: string
|
||||
privacyPolicyContent: string
|
||||
privacyPolicyUpdatedBy: string | null
|
||||
@@ -268,6 +407,7 @@ export interface AdminSiteSettingsResponse {
|
||||
showactsContent: string
|
||||
socialLinks: PublicSocialLink[]
|
||||
faq: FaqItem[]
|
||||
showactFormSchemaJson: string
|
||||
}
|
||||
|
||||
export interface AdminOperationalSettingsResponse {
|
||||
@@ -283,6 +423,7 @@ export interface AdminOperationalSettingsResponse {
|
||||
twitchClientSecretSet: boolean
|
||||
twitchRedirectUri: string
|
||||
twitchScope: string
|
||||
sessionIdleTimeoutHours: number
|
||||
maintenanceModeEnabled: boolean
|
||||
maintenanceTitle: string
|
||||
maintenanceMessage: string
|
||||
@@ -294,6 +435,9 @@ export interface AdminOptionalFeatureSettingsResponse {
|
||||
clipAdminMenuVisible: boolean
|
||||
clipSubmissionDisabledMessage: string
|
||||
showactApplicationsEnabled: boolean
|
||||
showactApplicationStartsAt: string | null
|
||||
showactApplicationEndsAt: string | null
|
||||
showactApplicationsOpenNow: boolean
|
||||
showactApplicationDisabledMessage: string
|
||||
sponsorsVisible: boolean
|
||||
}
|
||||
@@ -325,6 +469,7 @@ export interface AdminShowactApplicationItem {
|
||||
reviewNote: string | null
|
||||
createdAt: string
|
||||
reviewedAt: string | null
|
||||
fieldResponsesJson: string
|
||||
}
|
||||
|
||||
export interface AdminTeamPermission {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AuthSession {
|
||||
displayName: string
|
||||
role: AuthRole
|
||||
permissionKeys: string[]
|
||||
sessionIdleTimeoutHours: number
|
||||
mustChangePassword: boolean
|
||||
teamLogin: string | null
|
||||
boundTwitchUserId: string | null
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { FaqItem, PublicSocialLink } from './public'
|
||||
|
||||
export interface CreateNominationPayload {
|
||||
year: number
|
||||
categoryId: number
|
||||
categoryId?: number | null
|
||||
categoryGroupName?: string
|
||||
twitchUserId: string
|
||||
nominees?: string[]
|
||||
nominations?: Array<{
|
||||
@@ -74,6 +75,27 @@ export interface UpsertCategoryPayload {
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
}
|
||||
|
||||
export interface UpsertCategoryGroupPayload {
|
||||
groupName: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}
|
||||
|
||||
export interface AdminSubcategoryTemplatePayload {
|
||||
name: string
|
||||
slug: string
|
||||
sortOrder: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
}
|
||||
|
||||
export interface UpdateSeasonSubcategoryTemplatesPayload {
|
||||
templates: AdminSubcategoryTemplatePayload[]
|
||||
}
|
||||
|
||||
export interface UpsertCandidatePayload {
|
||||
@@ -132,6 +154,93 @@ export interface UpdateWorkflowRulesPayload {
|
||||
rules: UpdateWorkflowRulePayload[]
|
||||
}
|
||||
|
||||
export interface UpdateTrackingRulesPayload {
|
||||
source: {
|
||||
providerKey: string
|
||||
providerLabel: string
|
||||
baseUrl: string
|
||||
notesSummary: string
|
||||
showManualReviewNotesInReview: boolean
|
||||
}
|
||||
importantMetrics: Array<{
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
sourceSupport: string
|
||||
description: string
|
||||
requiredForAutoClassification: boolean
|
||||
showInReview: boolean
|
||||
showInAdminSummary: boolean
|
||||
manualOverrideAllowed: boolean
|
||||
windowKey: string
|
||||
autoSupportedWindowKeys: string[]
|
||||
providerFieldKey: string | null
|
||||
topCount: number | null
|
||||
minPrimaryCategorySharePercent: number | null
|
||||
minPrimaryCategoryHours: number | null
|
||||
maxDistinctCategoriesBeforeFlag: number | null
|
||||
ignoredCategories: string[]
|
||||
matchAwardCategoryAgainstTopCategories: boolean
|
||||
flagIfAwardCategoryNotInTopX: boolean
|
||||
flagIfCategorySpreadTooWide: boolean
|
||||
flagIfNoCategoryContextAvailable: boolean
|
||||
minValue: number | null
|
||||
maxValue: number | null
|
||||
}>
|
||||
optionalMetrics: Array<{
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
sourceSupport: string
|
||||
description: string
|
||||
requiredForAutoClassification: boolean
|
||||
showInReview: boolean
|
||||
showInAdminSummary: boolean
|
||||
manualOverrideAllowed: boolean
|
||||
windowKey: string
|
||||
autoSupportedWindowKeys: string[]
|
||||
providerFieldKey: string | null
|
||||
topCount: number | null
|
||||
minPrimaryCategorySharePercent: number | null
|
||||
minPrimaryCategoryHours: number | null
|
||||
maxDistinctCategoriesBeforeFlag: number | null
|
||||
ignoredCategories: string[]
|
||||
matchAwardCategoryAgainstTopCategories: boolean
|
||||
flagIfAwardCategoryNotInTopX: boolean
|
||||
flagIfCategorySpreadTooWide: boolean
|
||||
flagIfNoCategoryContextAvailable: boolean
|
||||
minValue: number | null
|
||||
maxValue: number | null
|
||||
}>
|
||||
flags: Array<{
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
severity: string
|
||||
description: string
|
||||
autoTriggerEnabled: boolean
|
||||
requiresManualReview: boolean
|
||||
blocksApproval: boolean
|
||||
adminNoteRequiredOnOverride: boolean
|
||||
}>
|
||||
manualReviewNotes: string
|
||||
}
|
||||
|
||||
export interface UpdateTrackingSourcePayload {
|
||||
source: {
|
||||
providerKey: string
|
||||
providerLabel: string
|
||||
baseUrl: string
|
||||
notesSummary: string
|
||||
showManualReviewNotesInReview: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateTrackingReviewNotesPayload {
|
||||
manualReviewNotes: string
|
||||
showManualReviewNotesInReview: boolean
|
||||
}
|
||||
|
||||
export interface SetAwardResultPayload {
|
||||
categoryId: number
|
||||
candidateId: number
|
||||
@@ -141,6 +250,7 @@ export interface ApproveNominationPayload {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
categoryId?: number | null
|
||||
reviewNote?: string
|
||||
}
|
||||
|
||||
@@ -148,6 +258,15 @@ export interface RejectNominationPayload {
|
||||
reviewNote?: string
|
||||
}
|
||||
|
||||
export interface ReopenRejectedNominationPayload {
|
||||
reviewNote?: string
|
||||
}
|
||||
|
||||
export interface UpdateNominationTrackingReviewPayload {
|
||||
status: string
|
||||
reviewNote?: string
|
||||
}
|
||||
|
||||
export interface UpdateNominationLinkBlacklistPayload {
|
||||
urls: string[]
|
||||
}
|
||||
@@ -160,6 +279,8 @@ export interface UpdateSiteSettingsPayload {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
privacyEmail: string
|
||||
privacyPolicyContent: string
|
||||
imprintUrl: string
|
||||
@@ -172,6 +293,7 @@ export interface UpdateSiteSettingsPayload {
|
||||
showactsContent: string
|
||||
socialLinks: PublicSocialLink[]
|
||||
faq: FaqItem[]
|
||||
showactFormSchemaJson: string
|
||||
}
|
||||
|
||||
export interface UpdateOperationalSettingsPayload {
|
||||
@@ -184,6 +306,7 @@ export interface UpdateOperationalSettingsPayload {
|
||||
twitchClientSecret?: string
|
||||
twitchRedirectUri: string
|
||||
twitchScope: string
|
||||
sessionIdleTimeoutHours: number
|
||||
maintenanceModeEnabled: boolean
|
||||
maintenanceTitle: string
|
||||
maintenanceMessage: string
|
||||
@@ -195,6 +318,8 @@ export interface UpdateOptionalFeatureSettingsPayload {
|
||||
clipAdminMenuVisible: boolean
|
||||
clipSubmissionDisabledMessage: string
|
||||
showactApplicationsEnabled: boolean
|
||||
showactApplicationStartsAt: string | null
|
||||
showactApplicationEndsAt: string | null
|
||||
showactApplicationDisabledMessage: string
|
||||
sponsorsVisible: boolean
|
||||
}
|
||||
@@ -215,12 +340,13 @@ export interface UpdateShowactStatusPayload {
|
||||
}
|
||||
|
||||
export interface CreateShowactApplicationPayload {
|
||||
artistName: string
|
||||
contactEmail: string
|
||||
contactDiscord: string
|
||||
platformUrl: string
|
||||
performanceType: string
|
||||
description: string
|
||||
technicalNotes: string
|
||||
referenceUrl: string
|
||||
artistName?: string
|
||||
contactEmail?: string
|
||||
contactDiscord?: string
|
||||
platformUrl?: string
|
||||
performanceType?: string
|
||||
description?: string
|
||||
technicalNotes?: string
|
||||
referenceUrl?: string
|
||||
fieldResponsesJson?: string
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface PublicSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
privacyEmail: string
|
||||
privacyPolicyContent: string
|
||||
socialLinks: PublicSocialLink[]
|
||||
@@ -73,10 +75,14 @@ export interface PublicSiteStatusResponse {
|
||||
export interface PublicFeatureFlags {
|
||||
clipSubmissionsEnabled: boolean
|
||||
clipReviewEnabled: boolean
|
||||
clipAdminMenuVisible: boolean
|
||||
clipSubmissionDisabledMessage: string
|
||||
showactApplicationsEnabled: boolean
|
||||
showactApplicationStartsAt: string | null
|
||||
showactApplicationEndsAt: string | null
|
||||
showactApplicationDisabledMessage: string
|
||||
sponsorsVisible: boolean
|
||||
showactFormSchemaJson: string
|
||||
}
|
||||
|
||||
export interface OverviewResponse {
|
||||
@@ -117,6 +123,8 @@ export interface PublicCategoryDetail {
|
||||
groupName: string
|
||||
description: string
|
||||
maxNomineesPerUser: number
|
||||
viewerRangeMin: number | null
|
||||
viewerRangeMax: number | null
|
||||
candidates: CandidateSummary[]
|
||||
}
|
||||
|
||||
@@ -161,7 +169,8 @@ export interface PublicSponsorsResponse {
|
||||
}
|
||||
|
||||
export interface UserNominationState {
|
||||
categoryId: number
|
||||
categoryId: number | null
|
||||
categoryGroupName: string
|
||||
nominees: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
export type ShowactFieldType = 'text' | 'email' | 'url' | 'textarea' | 'select' | 'radio' | 'multiselect' | 'checkbox'
|
||||
|
||||
export interface ShowactFormField {
|
||||
id: string
|
||||
type: ShowactFieldType
|
||||
label: string
|
||||
placeholder: string
|
||||
helpText?: string
|
||||
required: boolean
|
||||
isArtistName: boolean
|
||||
options: string[]
|
||||
allowOther?: boolean
|
||||
maxLength: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SHOWACT_SCHEMA: ShowactFormField[] = [
|
||||
{
|
||||
id: 'artist_name',
|
||||
type: 'text',
|
||||
label: 'Wie sollen wir dich nennen?',
|
||||
placeholder: 'Dein Name',
|
||||
required: true,
|
||||
isArtistName: true,
|
||||
options: [],
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
id: 'is_adult',
|
||||
type: 'radio',
|
||||
label: 'Bist du volljährig?',
|
||||
placeholder: '',
|
||||
required: true,
|
||||
isArtistName: false,
|
||||
options: ['Ja', 'Nein'],
|
||||
maxLength: 20,
|
||||
},
|
||||
{
|
||||
id: 'platform_url',
|
||||
type: 'url',
|
||||
label: 'Was ist dein Twitch/Youtube link?',
|
||||
placeholder: 'https://twitch.tv/... oder https://youtube.com/...',
|
||||
helpText: 'Disclaimer: Bitte achte darauf, dass die VODs angeschaltet sind.',
|
||||
required: true,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
id: 'contact_discord',
|
||||
type: 'text',
|
||||
label: 'Wie lautet dein Discord?',
|
||||
placeholder: 'nutzername',
|
||||
required: true,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 120,
|
||||
},
|
||||
{
|
||||
id: 'showact_roles',
|
||||
type: 'multiselect',
|
||||
label: 'Wofür möchtest du dich bewerben',
|
||||
placeholder: '',
|
||||
helpText: 'Du kannst dich für so viele Dinge bewerben, wie du möchtest.',
|
||||
required: true,
|
||||
isArtistName: false,
|
||||
options: [
|
||||
'Interview Gast',
|
||||
'Sänger/in',
|
||||
'3D Statist (Tänzer - ein VRM Model wird dafür benötigt)',
|
||||
'3D Statist (Hintergrund / Publikum - ein VRM Model wird dafür benötigt)',
|
||||
],
|
||||
allowOther: true,
|
||||
maxLength: 500,
|
||||
},
|
||||
{
|
||||
id: 'interview_highlight',
|
||||
type: 'textarea',
|
||||
label: 'Wenn du interviewt werden möchtest, erzähle uns etwas über dein Highlight aus 2025. Du kannst auch Sachen hinzufügen, über die du reden möchtest.',
|
||||
placeholder: 'Wenn dies nicht auf dich zutrifft dann schreibe "N/A"',
|
||||
required: false,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 1500,
|
||||
},
|
||||
{
|
||||
id: 'singing_references',
|
||||
type: 'textarea',
|
||||
label: 'Wenn du dich als Sänger/in bewirbst, dann verlinke hier paar von deinen vergangenen Gesangseinlagen. Du kannst uns auch gerne was darüber erzählen.',
|
||||
placeholder: 'Wenn dies nicht auf dich zutrifft dann schreibe "N/A"',
|
||||
required: false,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 1500,
|
||||
},
|
||||
{
|
||||
id: 'other_role_idea',
|
||||
type: 'textarea',
|
||||
label: 'Wenn du dich für eine andere Rolle als die oben gelisteten bewerben willst, dann erzähle uns hier von deiner Idee.',
|
||||
placeholder: '',
|
||||
required: false,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 1500,
|
||||
},
|
||||
{
|
||||
id: 'model_boundaries',
|
||||
type: 'textarea',
|
||||
label: 'Wenn du dich als ein 3D Statist bewirbst, gäbe es etwas was dir unangenehm wäre für dein Model (beispielsweise bestimmte Tänze)?',
|
||||
placeholder: '',
|
||||
required: false,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 1500,
|
||||
},
|
||||
{
|
||||
id: 'additional_notes',
|
||||
type: 'textarea',
|
||||
label: 'Gibt es noch was, was du uns mitteilen möchtest?',
|
||||
placeholder: '',
|
||||
required: false,
|
||||
isArtistName: false,
|
||||
options: [],
|
||||
maxLength: 1500,
|
||||
},
|
||||
{
|
||||
id: 'rules_confirmed',
|
||||
type: 'radio',
|
||||
label: 'Hast du die Regeln gelesen und bist mit denen Einverstanden?',
|
||||
placeholder: '',
|
||||
required: true,
|
||||
isArtistName: false,
|
||||
options: ['Ja', 'Nein'],
|
||||
maxLength: 20,
|
||||
},
|
||||
{
|
||||
id: 'technical_skip_confirmed',
|
||||
type: 'checkbox',
|
||||
label: 'Dir ist bewusst, dass wir Auftritte etc. überspringen falls diese technische Schwierigkeiten haben, welche man nicht beheben kann.',
|
||||
placeholder: '',
|
||||
required: true,
|
||||
isArtistName: false,
|
||||
options: ['Ja'],
|
||||
maxLength: 20,
|
||||
},
|
||||
]
|
||||
@@ -29,9 +29,23 @@ const router = useRouter()
|
||||
<span class="not-found__pillow-spark not-found__pillow-spark--two">✧</span>
|
||||
<span class="not-found__pillow-spark not-found__pillow-spark--three">★</span>
|
||||
<div class="not-found__pillow-shadow"></div>
|
||||
<div class="not-found__star-pillow">
|
||||
<span class="not-found__star-shine"></span>
|
||||
<span class="not-found__star-code">404</span>
|
||||
<div class="not-found__mobile">
|
||||
<div class="not-found__mobile-bar"></div>
|
||||
<div class="not-found__mobile-hanger not-found__mobile-hanger--left">
|
||||
<span class="not-found__mobile-thread"></span>
|
||||
<span class="not-found__mobile-number not-found__mobile-number--left">4</span>
|
||||
</div>
|
||||
<div class="not-found__mobile-hanger not-found__mobile-hanger--center">
|
||||
<span class="not-found__mobile-thread not-found__mobile-thread--long"></span>
|
||||
<span class="not-found__mobile-number not-found__mobile-number--center">0</span>
|
||||
</div>
|
||||
<div class="not-found__mobile-hanger not-found__mobile-hanger--right">
|
||||
<span class="not-found__mobile-thread"></span>
|
||||
<span class="not-found__mobile-number not-found__mobile-number--right">4</span>
|
||||
</div>
|
||||
<div class="not-found__mobile-cloud not-found__mobile-cloud--left"></div>
|
||||
<div class="not-found__mobile-cloud not-found__mobile-cloud--center"></div>
|
||||
<div class="not-found__mobile-cloud not-found__mobile-cloud--right"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="not-found__eyebrow">Oopsie im Sternenhimmel</p>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, Save, Wrench } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import AdminDemoPreviewCard from '../../components/admin/AdminDemoPreviewCard.vue'
|
||||
import AdminMaintenanceModeCard from '../../components/admin/AdminMaintenanceModeCard.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
|
||||
|
||||
const {
|
||||
operationalLoading,
|
||||
operationalSaving,
|
||||
operationalError,
|
||||
operationalSuccess,
|
||||
operationalForm,
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
demoPasswordHint,
|
||||
demoCredentialsComplete,
|
||||
hasUnsavedOperationalChanges,
|
||||
saveOperationalSettings,
|
||||
} = useAdminOperationalSettings()
|
||||
|
||||
watchAdminToast(operationalSuccess, operationalError)
|
||||
|
||||
function confirmDiscardSettingsChanges() {
|
||||
if (!hasUnsavedOperationalChanges.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
return window.confirm('Du hast ungespeicherte Änderungen in Demo und Wartung. Änderungen verwerfen?')
|
||||
}
|
||||
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!hasUnsavedOperationalChanges.value) return
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmDiscardSettingsChanges())
|
||||
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Demo & Wartung"
|
||||
description="Bündelt Demo-Schutz und Wartungsmodus an einem sinnvollen Betriebsort."
|
||||
:icon="Wrench"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button class="gap-2 rounded-2xl px-5" :disabled="operationalLoading || operationalSaving || !canManageOperationalSettings || !hasUnsavedOperationalChanges" @click="saveOperationalSettings">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ operationalSaving ? 'Speichert ...' : 'Betriebseinstellungen speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700">
|
||||
<KeyRound class="h-4 w-4" />
|
||||
Demo-Vorschau
|
||||
</div>
|
||||
<AdminDemoPreviewCard
|
||||
:form="operationalForm"
|
||||
:saving="operationalSaving"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
:demo-password="demoPasswordInput"
|
||||
:demo-password-hint="demoPasswordHint"
|
||||
:demo-password-set="demoPasswordSet"
|
||||
:demo-managed-by-database="demoManagedByDatabase"
|
||||
:demo-credentials-complete="demoCredentialsComplete"
|
||||
@update:demo-password="demoPasswordInput = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-amber-700">
|
||||
<Wrench class="h-4 w-4" />
|
||||
Wartungsmodus
|
||||
</div>
|
||||
<AdminMaintenanceModeCard
|
||||
:form="operationalForm"
|
||||
:saving="operationalSaving"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
@@ -23,6 +23,8 @@ const {
|
||||
totalVotes,
|
||||
} = useAdminAnalyticsManager()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const healthFilter = ref<string>('Alle')
|
||||
const groupFilter = ref<string>('Alle')
|
||||
|
||||
@@ -42,6 +44,43 @@ const healthStatusOptions = [
|
||||
{ key: 'Bereit', label: 'Bereit', dot: 'bg-sky-400' },
|
||||
{ key: 'Gewinner gesetzt', label: 'Gewinner', dot: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
function applyRouteFilters() {
|
||||
const rawHealth = Array.isArray(route.query.health) ? route.query.health[0] : route.query.health
|
||||
const rawGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
|
||||
|
||||
healthFilter.value = healthStatusOptions.some((option) => option.key === rawHealth) ? String(rawHealth) : 'Alle'
|
||||
groupFilter.value = groupOptions.value.includes(String(rawGroup)) ? String(rawGroup) : 'Alle'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.health, route.query.group, groupOptions.value.join('|')] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([healthFilter, groupFilter], ([nextHealth, nextGroup]) => {
|
||||
const currentHealth = Array.isArray(route.query.health) ? route.query.health[0] : route.query.health
|
||||
const currentGroup = Array.isArray(route.query.group) ? route.query.group[0] : route.query.group
|
||||
|
||||
const normalizedHealth = nextHealth === 'Alle' ? undefined : nextHealth
|
||||
const normalizedGroup = nextGroup === 'Alle' ? undefined : nextGroup
|
||||
|
||||
if (currentHealth === normalizedHealth && currentGroup === normalizedGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextQuery = { ...route.query } as Record<string, string>
|
||||
if (normalizedHealth) nextQuery.health = normalizedHealth
|
||||
else delete nextQuery.health
|
||||
|
||||
if (normalizedGroup) nextQuery.group = normalizedGroup
|
||||
else delete nextQuery.group
|
||||
|
||||
void router.replace({ query: nextQuery })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,6 +202,14 @@ const healthStatusOptions = [
|
||||
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
|
||||
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
|
||||
<RouterLink
|
||||
v-if="'to' in insight && insight.to"
|
||||
:to="insight.to"
|
||||
class="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-violet-700 hover:text-violet-800 hover:underline"
|
||||
>
|
||||
Öffnen
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -261,7 +308,7 @@ const healthStatusOptions = [
|
||||
<RouterLink
|
||||
v-for="category in filteredHealth"
|
||||
:key="category.id"
|
||||
to="/admin/categories"
|
||||
:to="`/admin/categories?group=${encodeURIComponent(category.groupName || 'Ohne Gruppe')}`"
|
||||
class="grid items-center gap-3 px-5 py-3.5 transition hover:bg-violet-50/50 sm:grid-cols-[minmax(0,1fr)_80px_80px_100px_140px]"
|
||||
>
|
||||
<!-- Name + group -->
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
categoryFilterOptions,
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
candidateIdentitySummaries,
|
||||
candidateWorkflowNotices,
|
||||
duplicateCandidateCount,
|
||||
acceptedCandidateCount,
|
||||
@@ -41,6 +42,8 @@ const {
|
||||
canSave,
|
||||
candidatePlatformOptions,
|
||||
selectedPlatformValue,
|
||||
editorIdentitySummary,
|
||||
editorRuleNotices,
|
||||
acceptanceStatusOptions,
|
||||
clipEmbedStatusOptions,
|
||||
readinessFilterOptions,
|
||||
@@ -146,6 +149,7 @@ watchAdminToast(adminMessage, adminError)
|
||||
:range-end="rangeEnd"
|
||||
:category-label-map="categoryLabelMap"
|
||||
:duplicate-candidate-keys="duplicateCandidateKeys"
|
||||
:candidate-identity-summaries="candidateIdentitySummaries"
|
||||
:candidate-workflow-notices="candidateWorkflowNotices"
|
||||
:acceptance-status-options="acceptanceStatusOptions"
|
||||
@edit="openEdit"
|
||||
@@ -162,6 +166,8 @@ watchAdminToast(adminMessage, adminError)
|
||||
:category-options="categoryOptions"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
:identity-summary="editorIdentitySummary"
|
||||
:rule-notices="editorRuleNotices"
|
||||
:acceptance-status-options="acceptanceStatusOptions"
|
||||
:clip-embed-status-options="clipEmbedStatusOptions"
|
||||
:can-save="canSave"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import AdminCategoryGroupModal from '../../components/admin/AdminCategoryGroupModal.vue'
|
||||
import AdminCategoryOverviewModal from '../../components/admin/AdminCategoryOverviewModal.vue'
|
||||
import AdminCategoryTemplatesModal from '../../components/admin/AdminCategoryTemplatesModal.vue'
|
||||
import AdminCategoryTreeWorkspace from '../../components/admin/AdminCategoryTreeWorkspace.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
|
||||
@@ -13,22 +17,55 @@ const {
|
||||
selectedSeasonId,
|
||||
query,
|
||||
statusFilter,
|
||||
selectedCategoryId,
|
||||
selectedGroupName,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
editForms,
|
||||
newCategoryForm,
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
groupModalOpen,
|
||||
editingGroupName,
|
||||
groupDraft,
|
||||
overviewTarget,
|
||||
overviewTitle,
|
||||
overviewSubtitle,
|
||||
overviewCategories,
|
||||
overviewCandidates,
|
||||
overviewNominations,
|
||||
filteredGroups,
|
||||
categoryStats,
|
||||
statusFilters,
|
||||
categoryToDelete,
|
||||
activeSubcategoryTemplates,
|
||||
focusedSubcategoryTemplate,
|
||||
subcategoryConfigModalOpen,
|
||||
subcategoryModalOpen,
|
||||
subcategoryDraft,
|
||||
subcategoryDrafts,
|
||||
subcategoryModalTitle,
|
||||
canSaveSubcategory,
|
||||
canSaveSubcategoryConfig,
|
||||
canCreateGroup,
|
||||
canSaveGroup,
|
||||
groupModalTitle,
|
||||
groupToDelete,
|
||||
deleting,
|
||||
saveCategory,
|
||||
createCategory,
|
||||
fillNewSlug,
|
||||
confirmDeleteCategory,
|
||||
openCreateGroupModal,
|
||||
openEditGroupModal,
|
||||
closeGroupModal,
|
||||
openSubcategoryConfigModal,
|
||||
closeSubcategoryConfigModal,
|
||||
openCreateSubcategoryModal,
|
||||
openEditSubcategoryModal,
|
||||
closeSubcategoryModal,
|
||||
fillSubcategorySlug,
|
||||
saveSubcategoryDraft,
|
||||
removeDraftSubcategory,
|
||||
saveSubcategoryConfig,
|
||||
saveGroupModal,
|
||||
confirmDeleteGroup,
|
||||
openGroupOverview,
|
||||
openCategoryOverview,
|
||||
isFocusedSubcategory,
|
||||
formatViewerRange,
|
||||
recommendedNominatorTarget,
|
||||
} = useAdminCategoryManager()
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
@@ -39,167 +76,136 @@ watchAdminToast(adminMessage, adminError)
|
||||
<AdminPageHeader
|
||||
eyebrow="Kategorien"
|
||||
:icon="Tags"
|
||||
description="Hauptkategorien und globale Unterkategorien werden getrennt gepflegt. Die Tree-Ansicht zeigt, wie viele Kandidaten und Nominierungen darunter liegen."
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(320px,0.82fr)_minmax(0,1.18fr)]">
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="grid gap-3">
|
||||
<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 v-model="query" 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="Kategorie, Gruppe oder Slug suchen" />
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = filter.key"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Card class="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-start">
|
||||
<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
|
||||
v-model="query"
|
||||
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="Hauptkategorie oder Unterkategorie suchen"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="max-h-[700px] space-y-2 overflow-y-auto p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 text-left transition"
|
||||
:class="selectedCategory?.id === category.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:bg-violet-50/50'"
|
||||
@click="selectedCategoryId = category.id"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = filter.key"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · /{{ category.slug }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span class="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-700">{{ category.candidates }} Kandidaten</span>
|
||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-700">Limit {{ category.maxNomineesPerUser }}</span>
|
||||
<span v-if="category.pending" class="rounded-full bg-amber-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-700">{{ category.pending }} Reviews</span>
|
||||
<span v-if="category.candidates === 0" class="rounded-full bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">leer</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-xl border border-violet-100 bg-white px-2.5 py-1 text-xs font-semibold text-violet-800">#{{ category.sortOrder }}</span>
|
||||
</div>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card v-if="selectedCategory" class="p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie bearbeiten</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ selectedCategory.name }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
|
||||
</div>
|
||||
<div class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Layers3 class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 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">Gruppe</span>
|
||||
<input v-model="editForms[selectedCategory.id].groupName" class="h-12 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input v-model="editForms[selectedCategory.id].name" class="h-12 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
|
||||
<input v-model="editForms[selectedCategory.id].slug" class="h-12 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" />
|
||||
</label>
|
||||
<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">Reihenfolge</span>
|
||||
<input v-model="editForms[selectedCategory.id].sortOrder" type="number" class="h-12 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" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
|
||||
<input v-model="editForms[selectedCategory.id].maxNomineesPerUser" type="number" class="h-12 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" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mt-4 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" class="gap-2 border border-rose-200 text-rose-600 hover:bg-rose-50" @click="categoryToDelete = selectedCategory">
|
||||
<Trash2 class="h-4 w-4" /> Löschen
|
||||
</Button>
|
||||
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
|
||||
{{ saving === selectedCategory.id ? 'Speichert …' : 'Kategorie speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<PlusCircle class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Kategorie anlegen</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<input v-model="newCategoryForm.groupName" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Gruppe" />
|
||||
<input v-model="newCategoryForm.name" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kategorie" />
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newCategoryForm.slug" class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="slug" />
|
||||
<button type="button" class="h-12 rounded-2xl border border-violet-100 bg-violet-50 px-4 text-xs font-semibold text-violet-700 transition hover:bg-violet-100" @click="fillNewSlug">
|
||||
Auto
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<input v-model="newCategoryForm.sortOrder" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Reihenfolge" />
|
||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="h-12 rounded-2xl border border-violet-200 px-4 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Limit" />
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="newCategoryForm.description" class="mt-4 min-h-20 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Beschreibung" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="saving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||
{{ saving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||
<Button variant="ghost" class="gap-2" @click="openSubcategoryConfigModal">
|
||||
<Layers3 class="h-4 w-4" />
|
||||
Unterkategorien konfigurieren
|
||||
</Button>
|
||||
<Button class="gap-2" :disabled="!selectedSeasonId || !canCreateGroup" @click="openCreateGroupModal">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Hauptkategorie anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = null">
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div v-for="stat in categoryStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="focusedSubcategoryTemplate"
|
||||
class="mt-4 rounded-2xl border border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-900"
|
||||
>
|
||||
Fokus auf Unterkategorie:
|
||||
<strong>{{ focusedSubcategoryTemplate.name }}</strong>
|
||||
<span class="text-sky-700">({{ formatViewerRange(focusedSubcategoryTemplate.viewerRangeMin, focusedSubcategoryTemplate.viewerRangeMax) }})</span>
|
||||
</div>
|
||||
|
||||
<p v-if="activeSubcategoryTemplates.length === 0" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
Lege zuerst mindestens eine Unterkategorie an, bevor du Hauptkategorien erzeugst.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<AdminCategoryTreeWorkspace
|
||||
:groups="filteredGroups"
|
||||
:selected-group-name="selectedGroupName"
|
||||
:is-focused="isFocusedSubcategory"
|
||||
:recommended-nominator-target="recommendedNominatorTarget"
|
||||
@edit-group="(group) => { selectedGroupName = group.name; openEditGroupModal(group) }"
|
||||
@overview-group="openGroupOverview"
|
||||
@overview-category="openCategoryOverview"
|
||||
/>
|
||||
|
||||
<AdminCategoryGroupModal
|
||||
:open="groupModalOpen"
|
||||
:title="groupModalTitle"
|
||||
:form="groupDraft"
|
||||
:can-save="canSaveGroup"
|
||||
:can-create-group="canCreateGroup"
|
||||
:saving="saving === (editingGroupName ?? 'new')"
|
||||
:is-editing="editingGroupName !== null"
|
||||
@close="closeGroupModal"
|
||||
@save="saveGroupModal"
|
||||
@delete="groupToDelete = filteredGroups.find((group) => group.name === editingGroupName) ?? null; closeGroupModal()"
|
||||
/>
|
||||
|
||||
<AdminCategoryTemplatesModal
|
||||
:open="subcategoryConfigModalOpen"
|
||||
:subcategories="subcategoryDrafts"
|
||||
:can-save="canSaveSubcategoryConfig"
|
||||
:saving="saving === 'templates'"
|
||||
:editor-open="subcategoryModalOpen"
|
||||
:editor-title="subcategoryModalTitle"
|
||||
:form="subcategoryDraft"
|
||||
:can-save-subcategory="canSaveSubcategory"
|
||||
@close="closeSubcategoryConfigModal"
|
||||
@save="saveSubcategoryConfig"
|
||||
@add-subcategory="openCreateSubcategoryModal"
|
||||
@edit-subcategory="openEditSubcategoryModal"
|
||||
@remove-subcategory="removeDraftSubcategory"
|
||||
@close-editor="closeSubcategoryModal"
|
||||
@save-subcategory="saveSubcategoryDraft"
|
||||
@fill-slug="fillSubcategorySlug"
|
||||
/>
|
||||
|
||||
<AdminCategoryOverviewModal
|
||||
:open="!!overviewTarget"
|
||||
:title="overviewTitle"
|
||||
:subtitle="overviewSubtitle"
|
||||
:categories="overviewCategories"
|
||||
:candidates="overviewCandidates"
|
||||
:nominations="overviewNominations"
|
||||
:recommended-nominator-target="recommendedNominatorTarget"
|
||||
@close="overviewTarget = null"
|
||||
/>
|
||||
|
||||
<Modal :open="!!groupToDelete" title="Hauptkategorie loeschen?" @close="groupToDelete = null">
|
||||
<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">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden aus diesem
|
||||
Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
|
||||
"<strong class="text-slate-800">{{ groupToDelete?.name }}</strong>" und alle automatisch darunter erzeugten Unterkategorien
|
||||
werden aus diesem Award-Jahr entfernt. Das laesst sich nicht rueckgaengig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
<Button variant="ghost" @click="groupToDelete = null">Abbrechen</Button>
|
||||
<Button class="gap-2 !bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteGroup">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
{{ deleting ? 'Loescht ...' : 'Endgueltig loeschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -199,16 +199,21 @@ watchAdminToast(adminMessage, adminError)
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div v-if="clipEmbeds[clip.id]" class="overflow-hidden rounded-2xl border border-violet-100 bg-slate-950 shadow-sm">
|
||||
<iframe
|
||||
class="aspect-video w-full"
|
||||
:src="clipEmbeds[clip.id]?.src"
|
||||
:title="clip.title || `${clip.platform} Clip ${clip.id}`"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
<div v-if="clipEmbeds[clip.id]">
|
||||
<div class="overflow-hidden rounded-2xl border border-violet-100 bg-slate-950 shadow-sm">
|
||||
<iframe
|
||||
class="aspect-video w-full"
|
||||
:src="clipEmbeds[clip.id]?.src"
|
||||
:title="clip.title || `${clip.platform} Clip ${clip.id}`"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
Falls der Clip nicht lädt oder nicht mehr verfügbar ist, öffne ihn direkt über den Clip-Link.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-5 text-sm font-medium leading-6 text-amber-800">
|
||||
Dieser Clip kann nicht eingebettet werden. Öffne ihn über den Clip-Link zur manuellen Prüfung.
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { FileText, Link2, MessageCircleQuestion, Mic2, Share2, ShieldCheck } from '@lucide/vue'
|
||||
import { FileText, Handshake, MessageCircleQuestion, Mic2, ScrollText, Send, Share2, ShieldCheck } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
|
||||
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
|
||||
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
|
||||
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
|
||||
import AdminContentLandingExtrasSection from '../../components/admin/AdminContentLandingExtrasSection.vue'
|
||||
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
|
||||
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
|
||||
import AdminContentPageSection from '../../components/admin/AdminContentPageSection.vue'
|
||||
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
|
||||
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
|
||||
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import Modal from '../../components/ui/Modal.vue'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
|
||||
|
||||
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy' | 'extras'
|
||||
type ContentEditorKey = 'imprint' | 'contact' | 'socials' | 'faq' | 'privacy' | 'showacts' | 'sponsors'
|
||||
|
||||
const store = useAwardsStore()
|
||||
|
||||
@@ -51,53 +48,42 @@ const {
|
||||
|
||||
watchAdminToast(saveMessage, saveError)
|
||||
|
||||
const footerPreviewOpen = ref(false)
|
||||
const footerPreviewKey = ref<FooterPreviewKey>('imprint')
|
||||
const faqPreviewOpen = ref(false)
|
||||
const activeContentEditor = ref<ContentEditorKey | null>(null)
|
||||
|
||||
const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, url: string, content: string }>>(() => ({
|
||||
imprint: {
|
||||
title: 'Impressum',
|
||||
url: form.imprintUrl,
|
||||
content: form.imprintContent,
|
||||
},
|
||||
contact: {
|
||||
title: 'Kontakt',
|
||||
url: form.contactUrl,
|
||||
content: form.contactContent,
|
||||
},
|
||||
sponsors: {
|
||||
title: 'Sponsoren & Partner',
|
||||
url: form.sponsorsUrl,
|
||||
content: form.sponsorsContent,
|
||||
},
|
||||
showacts: {
|
||||
title: 'Showacts',
|
||||
url: form.showactsUrl,
|
||||
content: form.showactsContent,
|
||||
},
|
||||
}))
|
||||
|
||||
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
|
||||
const activeFooterPreviewHtml = computed(() => privacyContentToHtml(activeFooterPreview.value.content))
|
||||
const contentEditors = computed(() => [
|
||||
{
|
||||
key: 'links' as const,
|
||||
eyebrow: 'Footer & Kontakt',
|
||||
title: 'Rechtliche Links',
|
||||
description: 'Kontaktwege, Impressum, Sponsoren- und Showact-Inhalte bearbeiten.',
|
||||
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl, form.showactsUrl].filter(Boolean).length}/4 URLs`,
|
||||
icon: Link2,
|
||||
key: 'imprint' as const,
|
||||
eyebrow: 'Rechtliches',
|
||||
title: 'Impressum',
|
||||
description: 'Impressumstext für das Footer-Modal bearbeiten.',
|
||||
metric: form.imprintContent ? 'Inhalt vorhanden' : 'Leer',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'extras' as const,
|
||||
key: 'contact' as const,
|
||||
eyebrow: 'Rechtliches',
|
||||
title: 'Kontakt',
|
||||
description: 'Kontakttext für das Footer-Modal bearbeiten.',
|
||||
metric: form.contactContent ? 'Inhalt vorhanden' : 'Leer',
|
||||
icon: Send,
|
||||
},
|
||||
{
|
||||
key: 'showacts' as const,
|
||||
eyebrow: 'Show Module',
|
||||
title: 'Showacts & Sponsoren',
|
||||
description: 'Landingpage-Module, Bewerbungen und Sponsorenlisten verwalten.',
|
||||
metric: `${store.adminShowactApplications.length + store.adminSponsors.length} Einträge`,
|
||||
title: 'Showacts',
|
||||
description: 'Seiteninhalt, Banner-Toggle und Bewerbungen verwalten.',
|
||||
metric: `${store.adminShowactApplications.length} Bewerbungen`,
|
||||
icon: Mic2,
|
||||
},
|
||||
{
|
||||
key: 'sponsors' as const,
|
||||
eyebrow: 'Partner',
|
||||
title: 'Sponsoren',
|
||||
description: 'Seiteninhalt, Sichtbarkeit und Sponsor-Kacheln verwalten.',
|
||||
metric: `${store.adminSponsors.length} Einträge`,
|
||||
icon: Handshake,
|
||||
},
|
||||
{
|
||||
key: 'socials' as const,
|
||||
eyebrow: 'Community',
|
||||
@@ -118,17 +104,13 @@ const contentEditors = computed(() => [
|
||||
key: 'privacy' as const,
|
||||
eyebrow: 'Rechtstexte',
|
||||
title: 'Datenschutz',
|
||||
description: 'Datenschutzerklärung mit Preview bearbeiten.',
|
||||
description: 'Datenschutz-E-Mail und Datenschutzerklärung bearbeiten.',
|
||||
metric: privacyUpdatedLabel.value,
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
])
|
||||
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
|
||||
|
||||
function openFooterPreview(key: FooterPreviewKey) {
|
||||
footerPreviewKey.value = key
|
||||
footerPreviewOpen.value = true
|
||||
}
|
||||
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
|
||||
|
||||
function openContentEditor(key: ContentEditorKey) {
|
||||
activeContentEditor.value = key
|
||||
@@ -179,12 +161,23 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
size="xl"
|
||||
@close="activeContentEditor = null"
|
||||
>
|
||||
<AdminContentLinksSection
|
||||
v-if="activeContentEditor === 'links'"
|
||||
<AdminContentPageSection
|
||||
v-if="activeContentEditor === 'imprint'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
content-key="imprintContent"
|
||||
label="Impressum"
|
||||
placeholder="Impressumstext..."
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
<AdminContentPageSection
|
||||
v-else-if="activeContentEditor === 'contact'"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
content-key="contactContent"
|
||||
label="Kontakt"
|
||||
placeholder="Kontakttext..."
|
||||
:save-site-settings="saveSiteSettings"
|
||||
@open-preview="openFooterPreview"
|
||||
/>
|
||||
<AdminContentSocialLinksSection
|
||||
v-else-if="activeContentEditor === 'socials'"
|
||||
@@ -223,7 +216,18 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
@open-preview="privacyPreviewOpen = true"
|
||||
/>
|
||||
<AdminContentLandingExtrasSection
|
||||
v-else-if="activeContentEditor === 'extras'"
|
||||
v-else-if="activeContentEditor === 'showacts'"
|
||||
section="showacts"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
<AdminContentLandingExtrasSection
|
||||
v-else-if="activeContentEditor === 'sponsors'"
|
||||
section="sponsors"
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -233,13 +237,6 @@ function openContentEditor(key: ContentEditorKey) {
|
||||
:updated-label="privacyUpdatedLabel"
|
||||
@close="privacyPreviewOpen = false"
|
||||
/>
|
||||
<AdminContentFooterPreviewModal
|
||||
:open="footerPreviewOpen"
|
||||
:title="activeFooterPreview.title"
|
||||
:url="activeFooterPreview.url"
|
||||
:content-html="activeFooterPreviewHtml"
|
||||
@close="footerPreviewOpen = false"
|
||||
/>
|
||||
<AdminContentFaqPreviewModal
|
||||
:open="faqPreviewOpen"
|
||||
:faq="form.faq"
|
||||
|
||||
@@ -11,15 +11,17 @@ import {
|
||||
FileClock,
|
||||
Menu,
|
||||
Settings,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
Trophy,
|
||||
UserCog,
|
||||
Users,
|
||||
Wrench,
|
||||
FileText,
|
||||
X,
|
||||
} from '@lucide/vue'
|
||||
|
||||
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||
import { getRiskMetricValue } from '../../lib/adminMetrics'
|
||||
@@ -50,7 +52,7 @@ const fullNavGroups = computed(() => [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Inhalte',
|
||||
label: 'Awards',
|
||||
items: [
|
||||
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
|
||||
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
|
||||
@@ -65,6 +67,11 @@ const fullNavGroups = computed(() => [
|
||||
permission: 'clips',
|
||||
badge: () => pendingClipCount.value > 0 ? `${pendingClipCount.value}` : clipWorkflowEnabled.value ? null : 'aus',
|
||||
}] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Landingpage',
|
||||
items: [
|
||||
{ label: 'Landingpage', to: '/admin/content', description: 'Public-Inhalte pflegen', icon: FileText, permission: 'content', badge: () => null },
|
||||
],
|
||||
},
|
||||
@@ -72,7 +79,6 @@ const fullNavGroups = computed(() => [
|
||||
label: 'Kontrolle',
|
||||
items: [
|
||||
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, permission: 'risk', badge: () => `${openRiskCount.value}` },
|
||||
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
|
||||
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, permission: 'audit', badge: () => null },
|
||||
],
|
||||
},
|
||||
@@ -81,7 +87,16 @@ const fullNavGroups = computed(() => [
|
||||
items: [
|
||||
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, permission: 'analytics', badge: () => null },
|
||||
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Einstellungen',
|
||||
items: [
|
||||
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, permission: 'settings', badge: () => null },
|
||||
{ label: 'Tracking Rules', to: '/admin/tracking-rules', description: 'Viewer-Tracking und Review-Regeln', icon: Settings2, permission: 'settings', badge: () => null },
|
||||
{ label: 'Demo & Wartung', to: '/admin/settings/access', description: 'Demo-Zugang und Sternenpause', icon: Wrench, permission: 'settings', badge: () => null },
|
||||
{ label: 'Workflow-Steuerung', to: '/admin/settings/workflows', description: 'Regeln und optionale Workflows', icon: ShieldCheck, permission: 'settings', badge: () => null },
|
||||
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -132,8 +147,6 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminToastViewport />
|
||||
|
||||
<!-- Mobile top bar -->
|
||||
<div class="sticky top-0 z-40 flex items-center gap-3 border-b border-violet-100 bg-white/90 px-4 py-3 backdrop-blur-sm xl:hidden">
|
||||
<button
|
||||
|
||||
@@ -1,128 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Ban, ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
|
||||
import { ClipboardList } from '@lucide/vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.vue'
|
||||
import AdminNominationLinkBlacklistModal from '../../components/admin/AdminNominationLinkBlacklistModal.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminReviewWorkspace from '../../components/admin/AdminReviewWorkspace.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const query = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
|
||||
const reviewModalOpen = ref(false)
|
||||
const blacklistModalOpen = ref(false)
|
||||
const selectedCategoryGroup = ref<string | null>(null)
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
|
||||
const filteredNominations = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
const heavyCategoryIds = new Set(categoryStats.value.filter((category) => category.pending >= 3).map((category) => category.id))
|
||||
return seasonDetail.value.pendingNominations.filter((nomination) => {
|
||||
const matchesCategory = !categoryFilter.value || nomination.categoryId === categoryFilter.value
|
||||
const category = categoryMap.value[nomination.categoryId]
|
||||
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === nomination.categoryId).length
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' ||
|
||||
(statusFilter.value === 'selected' && !!categoryFilter.value) ||
|
||||
(statusFilter.value === 'empty-category' && candidateCount === 0) ||
|
||||
(statusFilter.value === 'heavy' && heavyCategoryIds.has(nomination.categoryId))
|
||||
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, nomination.streamUrl]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
return matchesCategory && matchesStatus && matchesSearch && !!category
|
||||
})
|
||||
})
|
||||
const categoryStats = 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) => b.pending - a.pending || a.sortOrder - b.sortOrder),
|
||||
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,
|
||||
})),
|
||||
)
|
||||
const nominationStats = computed(() => [
|
||||
{ label: 'Offene Nominierungen', value: seasonDetail.value.pendingNominations.length, icon: Sparkles },
|
||||
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
|
||||
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
|
||||
])
|
||||
const reviewFocusCategories = computed(() => categoryStats.value.filter((category) => category.pending > 0).slice(0, 3))
|
||||
const statusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: seasonDetail.value.pendingNominations.length },
|
||||
{ key: 'empty-category' as const, label: 'Ohne Kandidatenbasis', count: seasonDetail.value.pendingNominations.filter((nomination) => !seasonDetail.value.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)).length },
|
||||
{ key: 'heavy' as const, label: 'Hoher Druck', count: categoryStats.value.filter((category) => category.pending >= 3).reduce((sum, category) => sum + category.pending, 0) },
|
||||
const mainCategoryStats = computed(() =>
|
||||
[...new Set(categoryStats.value.map((category) => category.groupName))]
|
||||
.map((groupName) => {
|
||||
const subcategories = categoryStats.value.filter((category) => category.groupName === groupName)
|
||||
return {
|
||||
groupName,
|
||||
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryGroupName === groupName).length,
|
||||
candidates: subcategories.reduce((sum, category) => sum + category.candidates, 0),
|
||||
subcategoryCount: subcategories.length,
|
||||
categoryIds: subcategories.map((category) => category.id),
|
||||
sortOrder: Math.min(...subcategories.map((category) => category.sortOrder)),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.groupName.localeCompare(b.groupName)),
|
||||
)
|
||||
const selectedCategoryFilterIds = computed(() =>
|
||||
selectedCategoryGroup.value === null
|
||||
? null
|
||||
: mainCategoryStats.value.find((category) => category.groupName === selectedCategoryGroup.value)?.categoryIds ?? null,
|
||||
)
|
||||
const reviewOverviewStats = computed(() => [
|
||||
{ label: 'Hauptkategorien', value: new Set(seasonDetail.value.categories.map((category) => category.groupName)).size },
|
||||
{ label: 'Unterkategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Nominierungen', value: seasonDetail.value.pendingNominations.length + seasonDetail.value.reviewedNominations.length },
|
||||
{ label: 'Noch zu bearbeiten', value: seasonDetail.value.pendingNominationGroups.length },
|
||||
])
|
||||
|
||||
function openReviewModal(nominationId?: number) {
|
||||
const nextQuery: LocationQueryRaw = { ...route.query, review: '1' }
|
||||
function applyRouteFilters() {
|
||||
const rawCategoryGroup = Array.isArray(route.query.categoryGroup) ? route.query.categoryGroup[0] : route.query.categoryGroup
|
||||
const rawCategoryId = Array.isArray(route.query.categoryId) ? route.query.categoryId[0] : route.query.categoryId
|
||||
const fallbackGroupName = mainCategoryStats.value[0]?.groupName ?? null
|
||||
|
||||
if (nominationId) {
|
||||
nextQuery.nominationId = String(nominationId)
|
||||
} else {
|
||||
delete nextQuery.nominationId
|
||||
if (typeof rawCategoryGroup === 'string' && rawCategoryGroup.trim()) {
|
||||
selectedCategoryGroup.value = mainCategoryStats.value.some((category) => category.groupName === rawCategoryGroup)
|
||||
? rawCategoryGroup
|
||||
: fallbackGroupName
|
||||
return
|
||||
}
|
||||
|
||||
reviewModalOpen.value = true
|
||||
void router.replace({ name: 'admin-nominations', query: nextQuery })
|
||||
}
|
||||
|
||||
const bulkRejecting = ref<number | null>(null)
|
||||
|
||||
async function bulkRejectCategory(categoryId: number) {
|
||||
if (!store.adminSelectedSeasonId || bulkRejecting.value !== null) return
|
||||
const ids = seasonDetail.value.pendingNominations
|
||||
.filter((n) => n.categoryId === categoryId)
|
||||
.map((n) => n.id)
|
||||
if (ids.length === 0) return
|
||||
bulkRejecting.value = categoryId
|
||||
try {
|
||||
await store.bulkRejectAdminNominations(ids, store.adminSelectedSeasonId)
|
||||
} finally {
|
||||
bulkRejecting.value = null
|
||||
categoryFilter.value = null
|
||||
const parsedCategoryId = Number(rawCategoryId)
|
||||
if (!Number.isFinite(parsedCategoryId) || parsedCategoryId <= 0) {
|
||||
selectedCategoryGroup.value = fallbackGroupName
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const NOM_PAGE_SIZE = 20
|
||||
const page = ref(1)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredNominations.value.length / NOM_PAGE_SIZE)))
|
||||
const pagedNominations = computed(() =>
|
||||
filteredNominations.value.slice((page.value - 1) * NOM_PAGE_SIZE, page.value * NOM_PAGE_SIZE),
|
||||
)
|
||||
const rangeStart = computed(() =>
|
||||
filteredNominations.value.length === 0 ? 0 : (page.value - 1) * NOM_PAGE_SIZE + 1,
|
||||
)
|
||||
const rangeEnd = computed(() => Math.min(page.value * NOM_PAGE_SIZE, filteredNominations.value.length))
|
||||
|
||||
watch([query, categoryFilter, statusFilter], () => {
|
||||
page.value = 1
|
||||
})
|
||||
watch(totalPages, (max) => {
|
||||
if (page.value > max) page.value = max
|
||||
})
|
||||
|
||||
function closeReviewModal() {
|
||||
const restQuery: LocationQueryRaw = { ...route.query }
|
||||
delete restQuery.review
|
||||
delete restQuery.nominationId
|
||||
|
||||
reviewModalOpen.value = false
|
||||
void router.replace({ name: 'admin-nominations', query: restQuery })
|
||||
selectedCategoryGroup.value = seasonDetail.value.categories.find((category) => category.id === parsedCategoryId)?.groupName
|
||||
?? fallbackGroupName
|
||||
?? null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.review, route.query.nominationId] as const,
|
||||
([review, nominationId]) => {
|
||||
reviewModalOpen.value = Boolean(review || nominationId)
|
||||
() => [route.query.categoryId, route.query.categoryGroup, seasonDetail.value.categories.length] as const,
|
||||
() => {
|
||||
applyRouteFilters()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -138,177 +92,69 @@ watch(
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<Card v-for="stat in nominationStats" :key="stat.label" class="p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ stat.label }}</p>
|
||||
<strong class="mt-3 block text-4xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<component :is="stat.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card
|
||||
v-for="item in reviewOverviewStats"
|
||||
:key="item.label"
|
||||
class="rounded-[22px] px-5 py-5 shadow-[0_10px_28px_rgba(168,145,214,0.08)]"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{{ item.label }}</p>
|
||||
<p class="mt-3 text-4xl font-bold text-slate-900">{{ item.value }}</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden border-amber-100 bg-amber-50/55">
|
||||
<div class="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-amber-700">Review-Fokus</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">
|
||||
{{ seasonDetail.pendingNominations.length }} offene Entscheidungen
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
Die Nominierungsseite bleibt eine Übersicht. Der Review-Fokus öffnet die Queue mit Entscheidungspanel, ohne die Liste dauerhaft aufzublähen.
|
||||
</p>
|
||||
<div v-if="reviewFocusCategories.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="category in reviewFocusCategories"
|
||||
:key="category.id"
|
||||
class="rounded-full border border-amber-200 bg-white/75 px-3 py-1 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
{{ category.name }} · {{ category.pending }}
|
||||
</span>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="grid gap-0 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside class="border-b border-violet-100 bg-[linear-gradient(180deg,#fcfaff_0%,#f8f4ff_100%)] xl:border-b-0 xl:border-r">
|
||||
<div class="border-b border-violet-100 px-5 py-5">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-violet-500">Kategorien</p>
|
||||
<h2 class="mt-1 text-2xl font-bold text-slate-900">Hauptkategorien</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-12 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-white px-5 text-sm font-semibold text-rose-700 shadow-sm shadow-rose-100/60 transition hover:bg-rose-50"
|
||||
@click="blacklistModalOpen = true"
|
||||
>
|
||||
<Ban class="h-4 w-4" />
|
||||
Link-Blacklist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
|
||||
@click="openReviewModal()"
|
||||
>
|
||||
Review-Fokus öffnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 p-4">
|
||||
<button
|
||||
v-for="category in mainCategoryStats"
|
||||
:key="category.groupName"
|
||||
type="button"
|
||||
class="group w-full rounded-[24px] border px-4 py-4 text-left transition"
|
||||
:class="selectedCategoryGroup === category.groupName ? 'border-violet-300 bg-white shadow-[0_18px_38px_rgba(168,145,214,0.18)] ring-2 ring-violet-100' : 'border-violet-100 bg-white/80 hover:border-violet-200 hover:bg-white'"
|
||||
@click="selectedCategoryGroup = category.groupName"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="selectedCategoryGroup === category.groupName"
|
||||
class="inline-flex h-2.5 w-2.5 rounded-full bg-violet-500 shadow-[0_0_0_6px_rgba(139,108,219,0.12)]"
|
||||
/>
|
||||
<div class="truncate text-lg font-semibold text-slate-900">{{ category.groupName }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm" :class="selectedCategoryGroup === category.groupName ? 'text-violet-700' : 'text-slate-500'">{{ category.subcategoryCount }} Tiers · {{ category.candidates }} Kandidaten</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full border px-3 text-base font-bold"
|
||||
:class="selectedCategoryGroup === category.groupName ? 'border-violet-300 bg-violet-600 text-white' : 'border-violet-100 bg-violet-50 text-violet-700'"
|
||||
>
|
||||
{{ category.pending }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<AdminReviewWorkspace
|
||||
:category-ids-filter="selectedCategoryFilterIds"
|
||||
:active-main-category="selectedCategoryGroup"
|
||||
:show-category-filter-chips="false"
|
||||
show-blacklist-button
|
||||
history-collapsible
|
||||
@open-blacklist="blacklistModalOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Wo staut es sich?</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="category in categoryStats"
|
||||
:key="category.id"
|
||||
class="group/cat grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 transition hover:bg-violet-50/50"
|
||||
:class="categoryFilter === category.id ? 'bg-violet-50/80' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 text-left"
|
||||
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
|
||||
>
|
||||
<span class="block truncate font-semibold text-slate-900">{{ category.name }}</span>
|
||||
<span class="mt-0.5 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
|
||||
</button>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
v-if="category.pending > 0"
|
||||
type="button"
|
||||
class="hidden h-7 items-center gap-1 rounded-full border border-rose-200 bg-white px-2.5 text-[11px] font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50 group-hover/cat:flex"
|
||||
:disabled="bulkRejecting === category.id"
|
||||
:title="`Alle ${category.pending} Nominierungen ablehnen`"
|
||||
@click.stop="bulkRejectCategory(category.id)"
|
||||
>
|
||||
<XCircle class="h-3 w-3" />
|
||||
{{ bulkRejecting === category.id ? '…' : 'Alle ablehnen' }}
|
||||
</button>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<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
|
||||
v-model="query"
|
||||
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="Nach Link, User oder Kategorie suchen"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
|
||||
@click="openReviewModal()"
|
||||
>
|
||||
Review-Fokus
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in statusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = filter.key"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div v-for="nomination in pagedNominations" :key="nomination.id" class="px-5 py-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
Limit {{ categoryMap[nomination.categoryId]?.maxNomineesPerUser ?? '-' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
|
||||
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
erst Kandidatenbasis klären
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center rounded-full bg-violet-600 px-4 text-xs font-semibold text-white shadow-sm shadow-violet-500/20 transition hover:bg-violet-500"
|
||||
@click="openReviewModal(nomination.id)"
|
||||
>
|
||||
Fall entscheiden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||
Keine Nominierungen passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaginationFooter
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:range-start="rangeStart"
|
||||
:range-end="rangeEnd"
|
||||
:filtered-count="filteredNominations.length"
|
||||
@update:page="page = $event"
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<AdminNominationReviewModal :open="reviewModalOpen" @close="closeReviewModal" />
|
||||
<AdminNominationLinkBlacklistModal :open="blacklistModalOpen" @close="blacklistModalOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
import AdminReviewDecisionPanel from '../../components/admin/AdminReviewDecisionPanel.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminReviewsHistorySection from '../../components/admin/AdminReviewsHistorySection.vue'
|
||||
import AdminReviewsQueueHeader from '../../components/admin/AdminReviewsQueueHeader.vue'
|
||||
import AdminReviewsQueueList from '../../components/admin/AdminReviewsQueueList.vue'
|
||||
import AdminReviewWorkspace from '../../components/admin/AdminReviewWorkspace.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { useAdminReviewsManager } from '../../components/admin/useAdminReviewsManager'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
|
||||
const {
|
||||
reviewSaving,
|
||||
blacklistSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
addStreamUrlToBlacklist,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
} = useAdminReviewsManager()
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -50,45 +18,7 @@ watchAdminToast(adminMessage, adminError)
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<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-6">
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
|
||||
<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"
|
||||
:can-approve-selected="canApproveSelected"
|
||||
:blacklist-saving="blacklistSaving"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
@platform-change="handlePlatformSelection"
|
||||
@approve="approveNomination"
|
||||
@reject="rejectNomination"
|
||||
@blacklist-link="addStreamUrlToBlacklist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminReviewsHistorySection
|
||||
:reviewed-nominations="reviewedNominations"
|
||||
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||
/>
|
||||
</div>
|
||||
<AdminReviewWorkspace />
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +22,6 @@ import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModa
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
hasShowDayPassed,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
SEASON_PHASES,
|
||||
@@ -61,8 +60,12 @@ const {
|
||||
latestSeasonAuditSummary,
|
||||
latestSeasonAuditMeta,
|
||||
canCreate,
|
||||
phaseGateway,
|
||||
activatePhase,
|
||||
activatePublicSeason,
|
||||
requestPhaseGateway,
|
||||
requestCompletionGateway,
|
||||
confirmPhaseGateway,
|
||||
openCreateModal,
|
||||
saveSeason,
|
||||
completeSeason,
|
||||
@@ -229,13 +232,13 @@ function requestPhaseActivation(row: (typeof timelineRows.value)[number]) {
|
||||
return
|
||||
}
|
||||
|
||||
void activatePhase(row.title)
|
||||
void requestPhaseGateway(row.title)
|
||||
}
|
||||
|
||||
function requestPhaseActivationByTitle(phase: string) {
|
||||
const row = timelineRows.value.find((item) => item.title === phase)
|
||||
if (!row) {
|
||||
void activatePhase(phase)
|
||||
void requestPhaseGateway(phase)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -244,21 +247,7 @@ function requestPhaseActivationByTitle(phase: string) {
|
||||
|
||||
function requestCompleteSeason() {
|
||||
if (completing.value || !canCompleteSelectedSeason.value) return
|
||||
|
||||
if (!hasShowDayPassed(form)) {
|
||||
phaseWarning.value = {
|
||||
action: 'complete',
|
||||
phase: 'Abgeschlossen',
|
||||
range: form.showDate ? `nach ${formatLocalDate(form.showDate)}` : 'nach der Award Show',
|
||||
reason: form.showDate
|
||||
? `Die Award Show ist laut Zeitplan erst am ${formatLocalDate(form.showDate)} bzw. noch nicht vorbei.`
|
||||
: 'Für die Award Show ist noch kein Datum hinterlegt.',
|
||||
detail: 'Wenn du jetzt beendest, werden Public-Aktionen gesperrt und das Jahr als abgeschlossen behandelt.',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
void completeSeason()
|
||||
void requestCompletionGateway()
|
||||
}
|
||||
|
||||
async function confirmPhaseWarning() {
|
||||
@@ -789,4 +778,111 @@ const readinessScore = computed(() => {
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
:open="!!phaseGateway"
|
||||
:title="phaseGateway?.title || 'Check-Gateway'"
|
||||
:subtitle="phaseGateway?.subtitle || ''"
|
||||
@close="phaseGateway = null"
|
||||
>
|
||||
<div v-if="phaseGateway" class="space-y-4">
|
||||
<div v-if="phaseGateway.blockers.length" class="rounded-[24px] border border-rose-200 bg-rose-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-rose-700" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-bold text-rose-900">Diese Punkte blockieren den nächsten Schritt.</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="item in phaseGateway.blockers"
|
||||
:key="`${item.label}-${item.to}`"
|
||||
class="rounded-2xl border border-rose-100 bg-white/90 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-rose-900">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-rose-800">{{ item.detail }}</p>
|
||||
<ul v-if="item.entries?.length" class="mt-2 space-y-1 text-sm text-rose-800">
|
||||
<li v-for="entry in item.entries" :key="`${entry.label}-${entry.to}`" class="truncate">
|
||||
<RouterLink :to="entry.to" class="inline-flex max-w-full items-center gap-1 hover:underline">
|
||||
<span class="shrink-0">•</span>
|
||||
<span class="truncate">{{ entry.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<RouterLink :to="item.to" class="inline-flex shrink-0 items-center gap-1 rounded-full border border-rose-200 bg-white px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50">
|
||||
Öffnen
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="phaseGateway.warnings.length" class="rounded-[24px] border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-700" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-bold text-amber-900">Diese Punkte solltest du bewusst prüfen.</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="item in phaseGateway.warnings"
|
||||
:key="`${item.label}-${item.to}`"
|
||||
class="rounded-2xl border border-amber-100 bg-white/90 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-amber-900">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-amber-800">{{ item.detail }}</p>
|
||||
<ul v-if="item.entries?.length" class="mt-2 space-y-1 text-sm text-amber-800">
|
||||
<li v-for="entry in item.entries" :key="`${entry.label}-${entry.to}`" class="truncate">
|
||||
<RouterLink :to="entry.to" class="inline-flex max-w-full items-center gap-1 hover:underline">
|
||||
<span class="shrink-0">•</span>
|
||||
<span class="truncate">{{ entry.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<RouterLink :to="item.to" class="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-200 bg-white px-3 py-1.5 text-xs font-semibold text-amber-700 hover:bg-amber-50">
|
||||
Öffnen
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-[22px] border border-violet-100 bg-white/80 p-4 text-sm">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-semibold text-slate-500">Zielphase</span>
|
||||
<strong class="text-right text-slate-900">{{ phaseGateway.phase }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-semibold text-slate-500">Blocker</span>
|
||||
<strong class="text-right text-slate-900">{{ phaseGateway.blockers.length }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="font-semibold text-slate-500">Warnungen</span>
|
||||
<strong class="text-right text-slate-900">{{ phaseGateway.warnings.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="button" variant="ghost" :disabled="saving || completing" @click="phaseGateway = null">
|
||||
Schließen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
:disabled="saving || completing || (phaseGateway?.blockers?.length ?? 0) > 0"
|
||||
@click="confirmPhaseGateway"
|
||||
>
|
||||
{{ phaseGateway?.confirmsLabel || 'Fortfahren' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Settings } from '@lucide/vue'
|
||||
import { Save, Settings } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import AdminOperationalSettingsCard from '../../components/admin/AdminOperationalSettingsCard.vue'
|
||||
import AdminOptionalFeaturesCard from '../../components/admin/AdminOptionalFeaturesCard.vue'
|
||||
import AdminOptionalFeaturesModal from '../../components/admin/AdminOptionalFeaturesModal.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminReleaseNotesCard from '../../components/admin/AdminReleaseNotesCard.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
|
||||
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
|
||||
import AdminWorkflowRulesCard from '../../components/admin/AdminWorkflowRulesCard.vue'
|
||||
import AdminSessionTimeoutCard from '../../components/admin/AdminSessionTimeoutCard.vue'
|
||||
import AdminTwitchAuthCard from '../../components/admin/AdminTwitchAuthCard.vue'
|
||||
import { watchAdminErrorToast, watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
|
||||
import { useAdminOptionalFeatures } from '../../components/admin/useAdminOptionalFeatures'
|
||||
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
|
||||
import { useAdminWorkflowRules } from '../../components/admin/useAdminWorkflowRules'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
|
||||
const canManageWorkflowRules = computed(() => authStore.hasPermission('settings'))
|
||||
const canManageOptionalFeatures = computed(() => authStore.hasPermission('settings'))
|
||||
|
||||
const {
|
||||
healthLoading,
|
||||
@@ -38,62 +32,25 @@ const {
|
||||
} = useAdminSettingsOverview()
|
||||
|
||||
const {
|
||||
operationalLoading,
|
||||
operationalSaving,
|
||||
operationalError,
|
||||
operationalSuccess,
|
||||
operationalForm,
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
twitchClientSecretSet,
|
||||
twitchAuthConfigured,
|
||||
twitchAuthManagedByDatabase,
|
||||
twitchClientSecretInput,
|
||||
demoPasswordHint,
|
||||
twitchSecretHint,
|
||||
demoCredentialsComplete,
|
||||
twitchAuthComplete,
|
||||
operationalSummary,
|
||||
hasUnsavedOperationalChanges,
|
||||
saveOperationalSettings,
|
||||
} = useAdminOperationalSettings()
|
||||
|
||||
const {
|
||||
hasUnsavedOptionalFeatureChanges,
|
||||
optionalFeaturesError,
|
||||
optionalFeaturesForm,
|
||||
optionalFeaturesLoading,
|
||||
optionalFeaturesModalOpen,
|
||||
optionalFeaturesSaving,
|
||||
optionalFeaturesSuccess,
|
||||
optionalFeaturesSummary,
|
||||
closeOptionalFeaturesModal,
|
||||
openOptionalFeaturesModal,
|
||||
saveOptionalFeatureSettings,
|
||||
} = useAdminOptionalFeatures()
|
||||
|
||||
const {
|
||||
hasUnsavedWorkflowRuleChanges,
|
||||
workflowError,
|
||||
workflowLoading,
|
||||
workflowRules,
|
||||
workflowRuleSummary,
|
||||
workflowSaving,
|
||||
workflowSuccess,
|
||||
saveWorkflowRules,
|
||||
updateWorkflowRule,
|
||||
} = useAdminWorkflowRules()
|
||||
|
||||
watchAdminToast(operationalSuccess, operationalError)
|
||||
watchAdminToast(optionalFeaturesSuccess, optionalFeaturesError)
|
||||
watchAdminToast(workflowSuccess, workflowError)
|
||||
watchAdminErrorToast(healthError)
|
||||
|
||||
function hasUnsavedSettingsChanges() {
|
||||
return hasUnsavedOperationalChanges.value
|
||||
|| hasUnsavedOptionalFeatureChanges.value
|
||||
|| hasUnsavedWorkflowRuleChanges.value
|
||||
}
|
||||
|
||||
function confirmDiscardSettingsChanges() {
|
||||
@@ -119,11 +76,16 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Einstellungen"
|
||||
description="Demo-Zugang, Wartungsmodus und Healthchecks."
|
||||
description="Übersicht, Twitch-Konfiguration, Session-Timeout und Systemstatus."
|
||||
:icon="Settings"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
<div class="flex justify-end">
|
||||
<Button class="gap-2 rounded-2xl px-5" :disabled="operationalSaving || !canManageOperationalSettings || !hasUnsavedOperationalChanges" @click="saveOperationalSettings">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ operationalSaving ? 'Speichert ...' : 'Kern-Einstellungen speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdminSettingsOverviewBoard
|
||||
:checks="checks"
|
||||
@@ -132,49 +94,26 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
:content-completion="contentCompletion"
|
||||
/>
|
||||
|
||||
<AdminOptionalFeaturesCard
|
||||
:loading="optionalFeaturesLoading"
|
||||
:saving="optionalFeaturesSaving"
|
||||
:can-manage="canManageOptionalFeatures"
|
||||
:summary="optionalFeaturesSummary"
|
||||
:disabled-message="optionalFeaturesForm.clipSubmissionDisabledMessage"
|
||||
@configure="openOptionalFeaturesModal"
|
||||
/>
|
||||
|
||||
<AdminReleaseNotesCard />
|
||||
|
||||
<AdminWorkflowRulesCard
|
||||
:rules="workflowRules"
|
||||
:loading="workflowLoading"
|
||||
:saving="workflowSaving"
|
||||
:dirty="hasUnsavedWorkflowRuleChanges"
|
||||
:can-manage="canManageWorkflowRules"
|
||||
:summary="workflowRuleSummary"
|
||||
@update-rule="updateWorkflowRule"
|
||||
@save="saveWorkflowRules"
|
||||
/>
|
||||
|
||||
<AdminOperationalSettingsCard
|
||||
v-model:demo-password="demoPasswordInput"
|
||||
v-model:twitch-client-secret="twitchClientSecretInput"
|
||||
<AdminTwitchAuthCard
|
||||
:form="operationalForm"
|
||||
:loading="operationalLoading"
|
||||
:saving="operationalSaving"
|
||||
:error="operationalError"
|
||||
:success="operationalSuccess"
|
||||
:demo-password-hint="demoPasswordHint"
|
||||
:demo-password-set="demoPasswordSet"
|
||||
:demo-managed-by-database="demoManagedByDatabase"
|
||||
:demo-credentials-complete="demoCredentialsComplete"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
:twitch-client-secret="twitchClientSecretInput"
|
||||
:twitch-secret-hint="twitchSecretHint"
|
||||
:twitch-client-secret-set="twitchClientSecretSet"
|
||||
:twitch-auth-configured="twitchAuthConfigured"
|
||||
:twitch-auth-managed-by-database="twitchAuthManagedByDatabase"
|
||||
:twitch-auth-complete="twitchAuthComplete"
|
||||
:summary="operationalSummary"
|
||||
:dirty="hasUnsavedOperationalChanges"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
@save="saveOperationalSettings"
|
||||
@update:twitch-client-secret="twitchClientSecretInput = $event"
|
||||
/>
|
||||
|
||||
<AdminSessionTimeoutCard
|
||||
:form="operationalForm"
|
||||
:saving="operationalSaving"
|
||||
:can-manage="canManageOperationalSettings"
|
||||
/>
|
||||
|
||||
<AdminSettingsDatabaseCard
|
||||
@@ -185,19 +124,5 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
||||
:health-loaded-label="healthLoadedLabel"
|
||||
:refresh-database-health="refreshDatabaseHealth"
|
||||
/>
|
||||
|
||||
<AdminOptionalFeaturesModal
|
||||
:open="optionalFeaturesModalOpen"
|
||||
:form="optionalFeaturesForm"
|
||||
:saving="optionalFeaturesSaving"
|
||||
:dirty="hasUnsavedOptionalFeatureChanges"
|
||||
:can-manage="canManageOptionalFeatures"
|
||||
@close="closeOptionalFeaturesModal"
|
||||
@save="saveOptionalFeatureSettings"
|
||||
@update:clip-submissions-enabled="optionalFeaturesForm.clipSubmissionsEnabled = $event"
|
||||
@update:clip-review-enabled="optionalFeaturesForm.clipReviewEnabled = $event"
|
||||
@update:clip-admin-menu-visible="optionalFeaturesForm.clipAdminMenuVisible = $event"
|
||||
@update:clip-submission-disabled-message="optionalFeaturesForm.clipSubmissionDisabledMessage = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Settings2 } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSettingsToggle from '../../components/admin/AdminSettingsToggle.vue'
|
||||
import { useAdminTrackingRules } from '../../components/admin/useAdminTrackingRules'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import NativeSelect from '../../components/ui/NativeSelect.vue'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import type { AdminTrackingMetricRule } from '../../types/awards'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const canManageTrackingRules = computed(() => authStore.hasPermission('settings'))
|
||||
const windowOptions = [
|
||||
{ label: '7 Tage', value: '7d' },
|
||||
{ label: '30 Tage', value: '30d' },
|
||||
{ label: '3 Monate', value: '90d' },
|
||||
{ label: 'All Time', value: 'all_time' },
|
||||
]
|
||||
|
||||
const {
|
||||
trackingLoading,
|
||||
rulesSaving,
|
||||
sourceSaving,
|
||||
notesSaving,
|
||||
trackingError,
|
||||
trackingSuccess,
|
||||
trackingSource,
|
||||
importantMetrics,
|
||||
optionalMetrics,
|
||||
trackingFlags,
|
||||
manualReviewNotes,
|
||||
trackingSummary,
|
||||
hasUnsavedTrackingSourceChanges,
|
||||
hasUnsavedTrackingRuleChanges,
|
||||
hasUnsavedTrackingNotesChanges,
|
||||
updateImportantMetric,
|
||||
updateOptionalMetric,
|
||||
updateTrackingFlag,
|
||||
saveTrackingSource,
|
||||
saveTrackingNotes,
|
||||
saveTrackingRules,
|
||||
} = useAdminTrackingRules()
|
||||
|
||||
watchAdminToast(trackingSuccess, trackingError)
|
||||
|
||||
const activeImportantMetrics = computed(() => importantMetrics.value.filter((rule) => rule.enabled))
|
||||
const disabledImportantMetrics = computed(() => importantMetrics.value.filter((rule) => !rule.enabled))
|
||||
const activeOptionalMetrics = computed(() => optionalMetrics.value.filter((rule) => rule.enabled))
|
||||
const disabledOptionalMetrics = computed(() => optionalMetrics.value.filter((rule) => !rule.enabled))
|
||||
|
||||
function sourceSupportLabel(sourceSupport: string) {
|
||||
return sourceSupport === 'auto' ? 'Auto' : sourceSupport === 'manual' ? 'Manuell' : 'Kontext'
|
||||
}
|
||||
|
||||
function sourceSupportClass(sourceSupport: string) {
|
||||
return sourceSupport === 'auto'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
: sourceSupport === 'manual'
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
: 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}
|
||||
|
||||
function windowSupportText(rule: AdminTrackingMetricRule) {
|
||||
if (rule.sourceSupport !== 'auto') {
|
||||
return 'Diese Metrik ist nur Review-Kontext und wird nicht automatisch von TwitchTracker befuellt.'
|
||||
}
|
||||
|
||||
if (rule.autoSupportedWindowKeys.includes(rule.windowKey)) {
|
||||
return 'Dieses Zeitfenster wird von der TwitchTracker API automatisch geliefert.'
|
||||
}
|
||||
|
||||
const labels = rule.autoSupportedWindowKeys
|
||||
.map((value) => windowOptions.find((item) => item.value === value)?.label ?? value)
|
||||
.join(', ')
|
||||
|
||||
return `Dieses Zeitfenster ist aktuell nicht automatisch verfuegbar. Auto geht nur fuer: ${labels || 'keine Zeitfenster'}.`
|
||||
}
|
||||
|
||||
function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'optional') {
|
||||
const parts: string[] = []
|
||||
if (section === 'important' && rule.requiredForAutoClassification) {
|
||||
parts.push('Fehlt diese Metrik, kann die Nominierung automatisch geflaggt werden.')
|
||||
}
|
||||
|
||||
if (rule.showInReview) {
|
||||
parts.push('Die Metrik wird im Nominierungsreview sichtbar.')
|
||||
} else {
|
||||
parts.push('Die Metrik wird im Nominierungsreview nicht angezeigt.')
|
||||
}
|
||||
|
||||
if (rule.sourceSupport === 'auto') {
|
||||
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstuetzt wird.')
|
||||
} else if (rule.sourceSupport === 'manual') {
|
||||
parts.push('Diese Metrik ist fuer manuelle Pflege gedacht.')
|
||||
} else {
|
||||
parts.push('Diese Metrik dient nur als Review-Kontext.')
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function isTopCategoriesRule(rule: AdminTrackingMetricRule) {
|
||||
return rule.key === 'top_categories_context'
|
||||
}
|
||||
|
||||
function ignoredCategoriesValue(rule: AdminTrackingMetricRule) {
|
||||
return (rule.ignoredCategories ?? []).join(', ')
|
||||
}
|
||||
|
||||
function updateIgnoredCategories(ruleKey: string, rawValue: string) {
|
||||
const ignoredCategories = rawValue
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
updateOptionalMetric(ruleKey, { ignoredCategories })
|
||||
}
|
||||
|
||||
function numericInputValue(event: Event) {
|
||||
const value = (event.target as HTMLInputElement | null)?.value?.trim() ?? ''
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function updateTopCount(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { topCount: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateMinPrimaryCategorySharePercent(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { minPrimaryCategorySharePercent: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateMinPrimaryCategoryHours(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { minPrimaryCategoryHours: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateMaxDistinctCategoriesBeforeFlag(ruleKey: string, event: Event) {
|
||||
updateOptionalMetric(ruleKey, { maxDistinctCategoriesBeforeFlag: numericInputValue(event) })
|
||||
}
|
||||
|
||||
function updateIgnoredCategoriesFromEvent(ruleKey: string, event: Event) {
|
||||
updateIgnoredCategories(ruleKey, (event.target as HTMLInputElement | null)?.value ?? '')
|
||||
}
|
||||
|
||||
function activateImportantMetric(ruleKey: string) {
|
||||
updateImportantMetric(ruleKey, { enabled: true })
|
||||
}
|
||||
|
||||
function activateOptionalMetric(ruleKey: string) {
|
||||
updateOptionalMetric(ruleKey, { enabled: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Tracking Rules"
|
||||
description="Nur Regeln zeigen, die im Review oder in der Auto-Bewertung wirklich etwas bewirken. Deaktivierte Metriken bleiben aus dem Nominierungsreview komplett raus."
|
||||
:icon="Settings2"
|
||||
/>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Tracking Source</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">TwitchTracker API-Quelle</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Diese Base URL wird fuer automatische TwitchTracker-Lookups verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || sourceSaving || !hasUnsavedTrackingSourceChanges || !canManageTrackingRules" @click="saveTrackingSource">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ sourceSaving ? 'Speichert ...' : 'Source speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 p-5 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Provider</p>
|
||||
<strong class="mt-1 block text-lg text-violet-900">{{ trackingSource.providerLabel }}</strong>
|
||||
<p class="mt-1 text-sm text-slate-500">Key: {{ trackingSource.providerKey }}</p>
|
||||
</div>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">API Base URL</span>
|
||||
<input
|
||||
v-model="trackingSource.baseUrl"
|
||||
type="url"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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"
|
||||
placeholder="https://twitchtracker.com/api"
|
||||
>
|
||||
<p class="text-sm text-amber-700">Erwartet wird die API-Basis, nicht die normale Website-Startseite.</p>
|
||||
</label>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Manual Review Notes</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Admin-Notizen fuer Fallback-Quellen</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Diese Notizen erscheinen im Nominierungsreview als Hilfe fuer den manuellen Check.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ notesSaving ? 'Speichert ...' : 'Notizen speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 p-5">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Notizen im Nominierungsreview anzeigen</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Wenn deaktiviert, bleiben die Admin-Notizen gespeichert, werden im Review aber komplett ausgeblendet.
|
||||
</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="trackingSource.showManualReviewNotesInReview"
|
||||
label="Review-Notizen"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Versteckt"
|
||||
@update:model-value="trackingSource.showManualReviewNotesInReview = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="manualReviewNotes"
|
||||
rows="10"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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 disabled:cursor-not-allowed disabled:bg-slate-50"
|
||||
placeholder="- SullyGnome Channel Summary - manueller Check fuer Category Fit - Sonderregeln fuer kleine Kanaele"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Wichtige aktiv</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.importantActive }}</strong>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Optionale aktiv</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.optionalActive }}</strong>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Flags aktiv</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.flagsActive }}</strong>
|
||||
</Card>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Manual Review</p>
|
||||
<strong class="mt-3 block text-3xl text-violet-900">{{ trackingSummary.manualReviewFlags }}</strong>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<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">Wichtige Metriken</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Diese Regeln wirken direkt auf automatische Flags oder auf die Sichtbarkeit im Nominierungsreview.</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || rulesSaving || !hasUnsavedTrackingRuleChanges || !canManageTrackingRules" @click="saveTrackingRules">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ rulesSaving ? 'Speichert ...' : 'Regeln speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="trackingLoading" class="p-5 text-sm text-slate-500">Tracking Rules werden geladen.</section>
|
||||
<section v-else class="space-y-4 p-5">
|
||||
<article v-for="rule in activeImportantMetrics" :key="rule.key" class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<span class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="sourceSupportClass(rule.sourceSupport)">
|
||||
{{ sourceSupportLabel(rule.sourceSupport) }}
|
||||
</span>
|
||||
<span v-if="rule.requiredForAutoClassification" class="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-rose-700">
|
||||
Pflicht fuer Auto-Check
|
||||
</span>
|
||||
<span v-if="rule.showInReview" class="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||
Im Review sichtbar
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
<p class="mt-2 text-xs text-amber-700">{{ windowSupportText(rule) }}</p>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.enabled"
|
||||
:label="`${rule.label} aktiv`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Aktiv"
|
||||
inactive-label="Inaktiv"
|
||||
@update:model-value="updateImportantMetric(rule.key, { enabled: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Zeitraum</span>
|
||||
<NativeSelect
|
||||
:model-value="rule.windowKey"
|
||||
:disabled="!canManageTrackingRules"
|
||||
:options="windowOptions"
|
||||
@update:model-value="updateImportantMetric(rule.key, { windowKey: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.requiredForAutoClassification"
|
||||
:label="`${rule.label} fuer Auto-Check erzwingen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Pflicht"
|
||||
inactive-label="Nicht Pflicht"
|
||||
@update:model-value="updateImportantMetric(rule.key, { requiredForAutoClassification: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.showInReview"
|
||||
:label="`${rule.label} im Nominierungsreview anzeigen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Ausgeblendet"
|
||||
@update:model-value="updateImportantMetric(rule.key, { showInReview: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 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">Wirkung</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
<li v-for="item in effectSummary(rule, 'important')" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="disabledImportantMetrics.length" class="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-4">
|
||||
<p class="text-sm font-semibold text-slate-700">Deaktivierte wichtige Metriken</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="rule in disabledImportantMetrics"
|
||||
:key="rule.key"
|
||||
type="button"
|
||||
class="rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-violet-200 hover:text-violet-700"
|
||||
:disabled="!canManageTrackingRules"
|
||||
@click="activateImportantMetric(rule.key)"
|
||||
>
|
||||
{{ rule.label }} aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<h2 class="text-xl font-bold text-slate-900">Optionale Metriken</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Nur aktive optionale Metriken koennen im Nominierungsreview erscheinen. Deaktivierte Metriken werden dort komplett ausgeblendet.</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 p-5">
|
||||
<article v-for="rule in activeOptionalMetrics" :key="rule.key" class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<span class="rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em]" :class="sourceSupportClass(rule.sourceSupport)">
|
||||
{{ sourceSupportLabel(rule.sourceSupport) }}
|
||||
</span>
|
||||
<span v-if="rule.showInReview" class="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sky-700">
|
||||
Im Review sichtbar
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
<p class="mt-2 text-xs text-amber-700">{{ windowSupportText(rule) }}</p>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.enabled"
|
||||
:label="`${rule.label} aktiv`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Aktiv"
|
||||
inactive-label="Inaktiv"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { enabled: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Zeitraum</span>
|
||||
<NativeSelect
|
||||
:model-value="rule.windowKey"
|
||||
:disabled="!canManageTrackingRules"
|
||||
:options="windowOptions"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { windowKey: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.showInReview"
|
||||
:label="`${rule.label} im Nominierungsreview anzeigen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Sichtbar"
|
||||
inactive-label="Ausgeblendet"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { showInReview: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isTopCategoriesRule(rule)" class="mt-4 space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext fuer Top Categories</p>
|
||||
<p class="mt-1 text-sm text-slate-600">Diese Einstellungen erzeugen aktuell keinen automatischen TwitchTracker-Abgleich, steuern aber den manuellen Review-Rahmen fuer Admins.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Top X Kategorien</span>
|
||||
<input
|
||||
:value="rule.topCount ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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"
|
||||
@input="updateTopCount(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Min Anteil Hauptkategorie %</span>
|
||||
<input
|
||||
:value="rule.minPrimaryCategorySharePercent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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"
|
||||
@input="updateMinPrimaryCategorySharePercent(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Min Stunden Hauptkategorie</span>
|
||||
<input
|
||||
:value="rule.minPrimaryCategoryHours ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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"
|
||||
@input="updateMinPrimaryCategoryHours(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Max unterschiedliche Kategorien</span>
|
||||
<input
|
||||
:value="rule.maxDistinctCategoriesBeforeFlag ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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"
|
||||
@input="updateMaxDistinctCategoriesBeforeFlag(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Ignorierte Kategorien</span>
|
||||
<input
|
||||
:value="ignoredCategoriesValue(rule)"
|
||||
type="text"
|
||||
:disabled="!canManageTrackingRules"
|
||||
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"
|
||||
placeholder="Just Chatting, Special Events"
|
||||
@input="updateIgnoredCategoriesFromEvent(rule.key, $event)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.matchAwardCategoryAgainstTopCategories"
|
||||
:label="`${rule.label} mit Award-Kategorie abgleichen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Abgleich aktiv"
|
||||
inactive-label="Nur Kontext"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { matchAwardCategoryAgainstTopCategories: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.flagIfAwardCategoryNotInTopX"
|
||||
:label="`${rule.label} Flag bei fehlendem Match`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Flag aktiv"
|
||||
inactive-label="Kein Flag"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { flagIfAwardCategoryNotInTopX: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.flagIfCategorySpreadTooWide"
|
||||
:label="`${rule.label} Flag bei zu breitem Mix`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Flag aktiv"
|
||||
inactive-label="Kein Flag"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { flagIfCategorySpreadTooWide: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.flagIfNoCategoryContextAvailable"
|
||||
:label="`${rule.label} Flag wenn kein Kontext da ist`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Flag aktiv"
|
||||
inactive-label="Kein Flag"
|
||||
@update:model-value="updateOptionalMetric(rule.key, { flagIfNoCategoryContextAvailable: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 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">Wirkung</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
<li v-for="item in effectSummary(rule, 'optional')" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="disabledOptionalMetrics.length" class="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 p-4">
|
||||
<p class="text-sm font-semibold text-slate-700">Deaktivierte optionale Metriken</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="rule in disabledOptionalMetrics"
|
||||
:key="rule.key"
|
||||
type="button"
|
||||
class="rounded-full border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-violet-200 hover:text-violet-700"
|
||||
:disabled="!canManageTrackingRules"
|
||||
@click="activateOptionalMetric(rule.key)"
|
||||
>
|
||||
{{ rule.label }} aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<h2 class="text-xl font-bold text-slate-900">Flags</h2>
|
||||
</section>
|
||||
<section class="grid gap-3 p-5">
|
||||
<article v-for="rule in trackingFlags" :key="rule.key" class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_180px_170px] xl:items-start">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
</div>
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Severity</span>
|
||||
<NativeSelect
|
||||
:model-value="rule.severity"
|
||||
:disabled="!canManageTrackingRules"
|
||||
:options="[
|
||||
{ label: 'Hoch', value: 'high' },
|
||||
{ label: 'Mittel', value: 'medium' },
|
||||
{ label: 'Niedrig', value: 'low' },
|
||||
]"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { severity: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.enabled"
|
||||
:label="`${rule.label} aktiv`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { enabled: $event })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.autoTriggerEnabled"
|
||||
:label="`${rule.label} automatisch ausloesen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Auto"
|
||||
inactive-label="Nur manuell"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { autoTriggerEnabled: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.requiresManualReview"
|
||||
:label="`${rule.label} braucht Review`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Review noetig"
|
||||
inactive-label="Nur Hinweis"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { requiresManualReview: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.blocksApproval"
|
||||
:label="`${rule.label} blockiert Approve`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Blockiert"
|
||||
inactive-label="Warnt nur"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { blocksApproval: $event })"
|
||||
/>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.adminNoteRequiredOnOverride"
|
||||
:label="`${rule.label} braucht Override-Notiz`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Notiz noetig"
|
||||
inactive-label="Optional"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user