Add winner archive, host image upload and live demo data
Deliver the demo-ready feature set and seed data so the live site can be presented end to end: - Winner archive: ArchivedWinner domain, admin CRUD endpoints/view/manager and public archive surface, backed by AddArchivedWinners migration. - Host presentation: host image upload and artist name on SiteSettings with public image endpoint and supporting migrations. - Clip submissions: idempotent table-ensure migration plus current-season demo clips for review workflows. - Demo seed data: sponsors, share links and 2025 archived winners, with a guarded RemoveDemoSeasons cleanup; all seeds guard against real data. - EnsureRuntimeSchemaParity migration to align runtime schema defensively. - Admin/home UI refinements; remove unused team role permissions modal and dead share-quick-links code. All seed and schema migrations are idempotent (IF NOT EXISTS / ON CONFLICT) and skip when real season data is present. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
@@ -1,17 +1,49 @@
|
||||
<template>
|
||||
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
„<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
„<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
||||
Alle verknüpften Daten werden beim Bestätigen mitgelöscht. Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm text-slate-600">
|
||||
Löschvorschau wird geladen...
|
||||
</div>
|
||||
|
||||
<div v-else-if="preview" class="rounded-2xl border border-rose-100 bg-rose-50/60 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-rose-600">Mitbetroffene Daten</p>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-rose-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-lg text-rose-700">{{ preview.nominationCount }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Clips</p>
|
||||
<strong class="mt-1 block text-lg text-rose-700">{{ preview.clipCount }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Votes</p>
|
||||
<strong class="mt-1 block text-lg text-rose-700">{{ preview.voteCount }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-rose-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Ergebnisse</p>
|
||||
<strong class="mt-1 block text-lg text-rose-700">{{ preview.resultCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3 text-sm text-amber-800">
|
||||
Die Löschvorschau konnte nicht geladen werden. Bitte prüfe die Fehlermeldung, bevor du fortfährst.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="$emit('confirm')">
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting || loading || !preview" @click="$emit('confirm')">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
@@ -21,12 +53,14 @@
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
import type { AdminCandidateDeletePreview, AdminCandidateItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
defineProps<{
|
||||
candidate: AdminCandidateItem | null
|
||||
preview: AdminCandidateDeletePreview | null
|
||||
loading: boolean
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -34,24 +34,32 @@
|
||||
<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>
|
||||
<p class="mt-1 text-sm text-slate-500">Diese Werte helfen bei mehrfachen Nominierungen und der Voting-Vorbereitung.</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">
|
||||
<div v-if="identitySummary" class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="group relative 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 class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-xl bg-slate-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||
Wie oft diese Person (erkannt über Handle + Plattform) saisonweit als Kandidat eingetragen ist – abgelehnte Einträge zählen nicht mit.
|
||||
<span class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-900" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<div class="group relative 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 class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-xl bg-slate-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||
Wie viele dieser Kandidaturen explizit auf „Angenommen" gesetzt wurden. 0 bedeutet: noch kein Eintrag dieser Person wurde bestätigt.
|
||||
<span class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-900" />
|
||||
</div>
|
||||
</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>
|
||||
<div class="group relative 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-lg text-violet-900">{{ identitySummary.nominationTally }}</strong>
|
||||
<div class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-56 -translate-x-1/2 rounded-xl bg-slate-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
|
||||
Wie viele Viewer diese Person im Nominierungsprozess vorgeschlagen haben. Beeinflusst keine Regeln, dient als Orientierung für die Kandidaten-Auswahl.
|
||||
<span class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-slate-900" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ruleNotices.length" class="mt-3 space-y-2">
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
<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>
|
||||
{{ avgViewerLabel(candidate.avgViewers) }}
|
||||
· {{ candidate.nominationTally.toLocaleString('de-DE') }} {{ candidate.nominationTally === 1 ? 'Nominierung' : 'Nominierungen' }}
|
||||
· {{ candidate.votes.toLocaleString('de-DE') }} {{ candidate.votes === 1 ? 'Vote' : 'Votes' }}
|
||||
</p>
|
||||
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||
Mögliches Duplikat in dieser Kategorie
|
||||
@@ -129,7 +127,6 @@ 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 }>
|
||||
}>()
|
||||
@@ -146,15 +143,6 @@ 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'
|
||||
}
|
||||
@@ -172,4 +160,10 @@ function embedLabel(value: string) {
|
||||
if (value === 'blocked') return 'Nicht nutzbar'
|
||||
return 'Embed prüfen'
|
||||
}
|
||||
|
||||
function avgViewerLabel(value: number | null) {
|
||||
return value != null
|
||||
? `Ø ${value.toLocaleString('de-DE')} Viewer`
|
||||
: 'Tracker offen'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:open="open"
|
||||
size="lg"
|
||||
:title="title"
|
||||
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewaehlten Season. Die Beschreibung erscheint auf der Landingpage in der Awards-Karte."
|
||||
subtitle="Diese Hauptkategorie bekommt automatisch alle globalen Unterkategorien der ausgewählten Season. Die Beschreibung erscheint auf der Landingpage in der Awards-Karte."
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@@ -47,7 +47,7 @@
|
||||
<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="Kurzer Beschreibungstext fuer die Awards-Sektion auf der Landingpage"
|
||||
placeholder="Kurzer Beschreibungstext für die Awards-Sektion auf der Landingpage"
|
||||
@input="form.description = ($event.target as HTMLTextAreaElement).value"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:open="open"
|
||||
size="xl"
|
||||
title="Unterkategorien konfigurieren"
|
||||
subtitle="Diese Viewer-Sektionen gelten fuer alle Hauptkategorien der ausgewaehlten Season."
|
||||
subtitle="Diese Viewer-Sektionen gelten für alle Hauptkategorien der ausgewählten Season."
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)]">
|
||||
@@ -11,7 +11,7 @@
|
||||
<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>
|
||||
<p class="text-sm text-slate-500">Die Hauptkategorien müssen sie nicht einzeln anlegen.</p>
|
||||
</div>
|
||||
<Button class="gap-2" @click="$emit('add-subcategory')">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
<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-3 font-semibold text-slate-700">Unterkategorie auswählen</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>
|
||||
|
||||
@@ -17,11 +17,45 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 p-6 md:grid-cols-2">
|
||||
<div class="md:col-span-2 grid gap-4 rounded-[24px] border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-fuchsia-50/60 p-4 sm:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div class="flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Hostbild Landingpage</p>
|
||||
<h3 class="mt-1 text-lg font-bold text-slate-900">Hero-Grafik austauschen</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Wird rechts im Landingpage-Hero angezeigt und nach dem Upload dauerhaft gespeichert. Erlaubt sind PNG, JPG oder WebP bis 8 MB.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-violet-600 px-4 py-2.5 text-sm font-bold text-white shadow-lg shadow-violet-500/20 transition hover:-translate-y-0.5 hover:bg-violet-700">
|
||||
<Upload class="h-4 w-4" />
|
||||
{{ hostImageUploading ? 'Lädt hoch ...' : 'Hostbild hochladen' }}
|
||||
<input class="sr-only" type="file" accept="image/png,image/jpeg,image/webp" :disabled="hostImageUploading || saving" @change="handleHostImageUpload" />
|
||||
</label>
|
||||
<span class="text-xs font-semibold text-slate-400">Aktuell: {{ form.hostImageUrl || 'Fallback-Bild' }}</span>
|
||||
</div>
|
||||
<p v-if="hostImageUploadError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
|
||||
{{ hostImageUploadError }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative min-h-[220px] overflow-hidden rounded-[22px] border border-white/80 bg-[#f4eefb] shadow-inner shadow-violet-100">
|
||||
<img
|
||||
:src="form.hostImageUrl || '/assets/amaterasu2sei_2.png'"
|
||||
alt="Aktuelles Hostbild"
|
||||
class="absolute inset-x-0 bottom-0 mx-auto h-[260px] w-auto object-contain drop-shadow-2xl"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-[#f4eefb] to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Name</span>
|
||||
<input v-model="form.hostDisplayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Artist Name</span>
|
||||
<input v-model="form.hostArtistName" type="text" placeholder="z.B. Illustrator:in des Hostbilds" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Tagline</span>
|
||||
<input v-model="form.hostTagline" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
@@ -42,7 +76,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Globe2, Save } from '@lucide/vue'
|
||||
import { Globe2, Save, Upload } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
@@ -51,6 +85,9 @@ import type { AdminContentForm } from './adminContentTypes'
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
hostImageUploading: boolean
|
||||
hostImageUploadError: string
|
||||
handleHostImageUpload: (event: Event) => Promise<void>
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ const showactStatusText = computed(() => {
|
||||
if (settingsForm.showactApplicationsOpenNow) {
|
||||
return parts.length > 0
|
||||
? `Bewerbungen sind aktuell offen. ${parts.join(' · ')}.`
|
||||
: 'Bewerbungen sind aktuell offen und das Modal bleibt fuer Einreichungen verfuegbar.'
|
||||
: 'Bewerbungen sind aktuell offen und das Modal bleibt für Einreichungen verfügbar.'
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
@@ -470,7 +470,7 @@ onMounted(loadLandingExtras)
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="store.adminShowactApplications.length === 0" class="p-4 text-sm text-slate-500">
|
||||
Noch keine Bewerbungen fuer dieses Jahr.
|
||||
Noch keine Bewerbungen für dieses Jahr.
|
||||
</div>
|
||||
<div v-else class="divide-y divide-violet-100">
|
||||
<article v-for="application in store.adminShowactApplications" :key="application.id" class="space-y-3 p-4">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
v-model="form.streamBannerEyebrow"
|
||||
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="Das grosse Finale"
|
||||
placeholder="Das große Finale"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
|
||||
@@ -87,7 +87,7 @@ async function saveBlacklist() {
|
||||
<Modal
|
||||
:open="open"
|
||||
title="Link-Blacklist"
|
||||
subtitle="Blockiert generische oder unerwuenschte Stream-Links direkt bei der oeffentlichen Nominierung."
|
||||
subtitle="Blockiert generische oder unerwünschte Stream-Links direkt bei der öffentlichen Nominierung."
|
||||
size="lg"
|
||||
@close="emit('close')"
|
||||
>
|
||||
|
||||
@@ -27,7 +27,6 @@ const props = defineProps<{
|
||||
platforms: string[]
|
||||
} | null
|
||||
trackingReviewNotes?: string
|
||||
selectedNominationHasBlockingFlag?: boolean
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
blacklistSaving: boolean
|
||||
@@ -109,7 +108,6 @@ onBeforeUnmount(() => {
|
||||
: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"
|
||||
|
||||
@@ -253,7 +253,7 @@ function toneClasses(tone: AdminSettingsTone) {
|
||||
/>
|
||||
</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.
|
||||
Der Logout reagiert auf echte Nutzeraktivität wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und läuft spätestens nach 30 Tagen absolut ab.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
@@ -25,7 +26,6 @@ const props = defineProps<{
|
||||
platforms: string[]
|
||||
} | null
|
||||
trackingReviewNotes?: string
|
||||
selectedNominationHasBlockingFlag?: boolean
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
blacklistSaving: boolean
|
||||
@@ -159,12 +159,23 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
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 class="flex flex-wrap items-center justify-between gap-3">
|
||||
<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>
|
||||
<RouterLink
|
||||
to="/admin/tracking-rules"
|
||||
class="inline-flex items-center rounded-full border border-violet-200 bg-white px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:border-violet-300 hover:bg-violet-50"
|
||||
>
|
||||
Zur Konfiguration
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
|
||||
<p class="mt-2 text-xs leading-5 text-slate-500">
|
||||
Diese Tracking-Metriken blockieren keine Annahme oder Verwerfung. Sie zeigen nur, welche Review-Regel und welches Zeitfenster aktuell angewandt werden.
|
||||
</p>
|
||||
|
||||
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -172,7 +183,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
: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>
|
||||
{{ flag.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +201,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
</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>
|
||||
<p class="mt-1 text-xs text-slate-500">Review-Regel · {{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -271,12 +282,7 @@ function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
</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>
|
||||
Anzeigename, Handle, Plattform und Tier sind Pflicht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ const {
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
trackingReviewNotes,
|
||||
selectedNominationHasBlockingFlag,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
@@ -130,7 +129,6 @@ watchAdminToast(adminMessage, adminError)
|
||||
: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"
|
||||
|
||||
@@ -87,10 +87,10 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
|
||||
<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"
|
||||
v-if="nomination.trackingFlags.length > 0"
|
||||
class="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-amber-700"
|
||||
>
|
||||
Blockierende Flags
|
||||
Tracking-Hinweise
|
||||
</span>
|
||||
<span
|
||||
v-else-if="nomination.requiresManualReview"
|
||||
@@ -102,7 +102,7 @@ function avgViewerLabel(nomination: AdminNominationReviewGroup) {
|
||||
v-if="!nomination.suggestedCategoryName"
|
||||
class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-slate-600"
|
||||
>
|
||||
Tier manuell waehlen
|
||||
Tier manuell wählen
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs font-medium text-slate-400">
|
||||
|
||||
@@ -36,7 +36,7 @@ const props = defineProps<{
|
||||
</span>
|
||||
</label>
|
||||
<div class="rounded-[22px] border border-violet-100 bg-violet-50/60 p-4 text-sm leading-6 text-slate-600 sm:col-span-2">
|
||||
Der Live-Link fuer das Finale kommt zentral aus dem Landingpage-Modul, damit Jahre und Banner nicht doppelt gepflegt werden.
|
||||
Der Live-Link für das Finale kommt zentral aus dem Landingpage-Modul, damit Jahre und Banner nicht doppelt gepflegt werden.
|
||||
</div>
|
||||
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
|
||||
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
|
||||
|
||||
@@ -39,7 +39,7 @@ defineProps<{
|
||||
/>
|
||||
</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.
|
||||
Der Logout reagiert auf echte Nutzeraktivität wie Klicks, Tasten, Scrollen oder Touch. Die Session bleibt serverseitig trotzdem autoritativ und läuft spätestens nach 30 Tagen absolut ab.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -175,8 +175,8 @@ function removeOption(field: ShowactFormField, optionIndex: number) {
|
||||
|
||||
function choiceFieldHint(field: ShowactFormField) {
|
||||
if (field.type === 'select') return 'Wird auf der Landingpage als Dropdown mit genau einer Auswahl gezeigt.'
|
||||
if (field.type === 'radio') return 'Wird als sichtbare Liste gezeigt, aus der genau ein Eintrag gewaehlt werden kann.'
|
||||
if (field.type === 'multiselect') return 'Wird als Liste mit mehreren ankreuzbaren Optionen fuer ein gemeinsames Feld gerendert.'
|
||||
if (field.type === 'radio') return 'Wird als sichtbare Liste gezeigt, aus der genau ein Eintrag gewählt werden kann.'
|
||||
if (field.type === 'multiselect') return 'Wird als Liste mit mehreren ankreuzbaren Optionen für ein gemeinsames Feld gerendert.'
|
||||
if (field.type === 'checkbox') return 'Jede Option wird als eigene benennbare Checkbox auf der Landingpage angezeigt.'
|
||||
return ''
|
||||
}
|
||||
@@ -487,7 +487,7 @@ function choiceFieldHint(field: ShowactFormField) {
|
||||
@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.
|
||||
Noch keine Felder für die Vorschau vorhanden.
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
@@ -521,7 +521,7 @@ function choiceFieldHint(field: ShowactFormField) {
|
||||
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>{{ field.placeholder || 'Bitte wählen' }}</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">
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronRight, LockKeyhole, Minus, Save } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { AdminTeamPermission, AdminTeamRole } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
interface PermissionGroup {
|
||||
label: string
|
||||
items: AdminTeamPermission[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
saving: boolean
|
||||
hasRoleChanges: boolean
|
||||
roles: AdminTeamRole[]
|
||||
permissions: AdminTeamPermission[]
|
||||
permissionGroups: PermissionGroup[]
|
||||
roleHasPermission: (roleKey: string, permissionKey: string) => boolean
|
||||
setRolePermission: (roleKey: string, permissionKey: string, checked: boolean) => void
|
||||
saveRolePermissions: () => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const selectedRoleKey = ref<string | null>(null)
|
||||
|
||||
const selectedRole = computed(() =>
|
||||
props.roles.find((role) => role.key === selectedRoleKey.value) ?? props.roles[0] ?? null,
|
||||
)
|
||||
|
||||
const selectedPermissionGroups = computed(() => {
|
||||
if (!selectedRole.value) return []
|
||||
return props.permissionGroups.map((group) => ({
|
||||
...group,
|
||||
grantedCount: group.items.filter((permission) => props.roleHasPermission(selectedRole.value!.key, permission.key)).length,
|
||||
}))
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.open, props.roles.map((role) => role.key).join('|')] as const,
|
||||
([open]) => {
|
||||
if (!open) return
|
||||
if (!selectedRoleKey.value || !props.roles.some((role) => role.key === selectedRoleKey.value)) {
|
||||
selectedRoleKey.value = props.roles[0]?.key ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function rolePermissionLocked(roleKey: string) {
|
||||
return roleKey === 'owner' || roleKey === 'creator'
|
||||
}
|
||||
|
||||
function rolePermissionCount(roleKey: string) {
|
||||
return props.permissions.filter((permission) => props.roleHasPermission(roleKey, permission.key)).length
|
||||
}
|
||||
|
||||
function permissionModeLabel(permission: AdminTeamPermission) {
|
||||
return permission.readOnlySupported ? 'Lesen oder Schreiben' : 'Schreiben'
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await props.saveRolePermissions()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="Rollenrechte"
|
||||
subtitle="Rolle im Navigator wählen und Berechtigungen im Detail pflegen."
|
||||
size="xl"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside class="space-y-3">
|
||||
<div class="rounded-[24px] border border-violet-100 bg-violet-50/55 p-4">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Navigator</p>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
Jede Rolle zeigt ihre Rechte einzeln. Das ist kompakter als die alte Matrix und passt zum aktuellen Admin-Workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="role in roles"
|
||||
:key="role.key"
|
||||
type="button"
|
||||
class="w-full rounded-[24px] border p-4 text-left transition"
|
||||
:class="selectedRole?.key === role.key
|
||||
? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#fff4e6] shadow-[0_18px_40px_rgba(139,108,219,0.14)]'
|
||||
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||
@click="selectedRoleKey = role.key"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500">{{ role.label }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">{{ role.description }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl border transition"
|
||||
:class="selectedRole?.key === role.key ? 'border-violet-200 bg-white text-violet-700' : 'border-violet-100 bg-violet-50 text-violet-500'"
|
||||
>
|
||||
<LockKeyhole v-if="rolePermissionLocked(role.key)" class="h-4 w-4" />
|
||||
<ChevronRight v-else class="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-violet-700">
|
||||
{{ rolePermissionCount(role.key) }}/{{ permissions.length }} Rechte
|
||||
</span>
|
||||
<span
|
||||
v-if="rolePermissionLocked(role.key)"
|
||||
class="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-slate-500"
|
||||
>
|
||||
fixiert
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="selectedRole" class="space-y-4">
|
||||
<div class="rounded-[28px] border border-violet-100 bg-white/95 p-5 shadow-[0_18px_40px_rgba(124,92,255,0.08)]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rolle</p>
|
||||
<h3 class="mt-1 text-2xl font-bold text-slate-950">{{ selectedRole.label }}</h3>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">{{ selectedRole.description }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-bold text-violet-700">
|
||||
{{ rolePermissionCount(selectedRole.key) }}/{{ permissions.length }} Rechte aktiv
|
||||
</span>
|
||||
<span
|
||||
v-if="rolePermissionLocked(selectedRole.key)"
|
||||
class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-bold text-slate-500"
|
||||
>
|
||||
Systemrolle
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="rolePermissionLocked(selectedRole.key)"
|
||||
class="mt-4 flex items-start gap-3 rounded-[22px] border border-slate-200 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
|
||||
>
|
||||
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
|
||||
<p>Owner und Creator bleiben auf Vollzugriff fixiert und können hier nur eingesehen werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="group in selectedPermissionGroups"
|
||||
:key="group.label"
|
||||
class="rounded-[28px] border border-violet-100 bg-white/92 p-5 shadow-[0_14px_34px_rgba(124,92,255,0.06)]"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-violet-100 pb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Bereich</p>
|
||||
<h4 class="mt-1 text-lg font-bold text-slate-950">{{ group.label }}</h4>
|
||||
</div>
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-bold text-violet-700">
|
||||
{{ group.grantedCount }}/{{ group.items.length }} aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3">
|
||||
<div
|
||||
v-for="permission in group.items"
|
||||
:key="permission.key"
|
||||
class="grid gap-3 rounded-[24px] border border-violet-100 bg-violet-50/35 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-base font-bold text-slate-950">{{ permission.label }}</p>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-violet-700">
|
||||
{{ permissionModeLabel(permission) }}
|
||||
</span>
|
||||
<span class="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-bold text-slate-500">
|
||||
{{ permission.menuPath }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">{{ permission.description }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-sm font-bold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
:class="[
|
||||
roleHasPermission(selectedRole.key, permission.key)
|
||||
? rolePermissionLocked(selectedRole.key)
|
||||
? 'border-slate-200 bg-slate-100 text-slate-500'
|
||||
: 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500'
|
||||
: rolePermissionLocked(selectedRole.key)
|
||||
? 'border-slate-200 bg-slate-50 text-slate-300'
|
||||
: 'border-violet-200 bg-white text-violet-700 hover:bg-violet-50',
|
||||
]"
|
||||
:disabled="rolePermissionLocked(selectedRole.key)"
|
||||
:aria-label="`${selectedRole.label}: ${permission.label}`"
|
||||
:aria-pressed="roleHasPermission(selectedRole.key, permission.key)"
|
||||
@click="setRolePermission(selectedRole.key, permission.key, !roleHasPermission(selectedRole.key, permission.key))"
|
||||
>
|
||||
<Check v-if="roleHasPermission(selectedRole.key, permission.key)" class="h-4 w-4" :stroke-width="3" />
|
||||
<Minus v-else class="h-4 w-4" :stroke-width="3" />
|
||||
{{ roleHasPermission(selectedRole.key, permission.key) ? 'Aktiv' : 'Aus' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="button" variant="ghost" @click="emit('close')">Schließen</Button>
|
||||
<Button type="button" :disabled="saving || !hasRoleChanges" @click="save">
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
{{ saving ? 'Speichert...' : 'Speichern' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<Modal
|
||||
:open="open && leaderboardEntry !== null"
|
||||
:title="leaderboardEntry?.displayName || 'Kandidatendetails'"
|
||||
:subtitle="subtitle"
|
||||
size="lg"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template v-if="leaderboardEntry">
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<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.14em] text-slate-500">Rang</p>
|
||||
<strong class="mt-1 block text-xl text-slate-950">#{{ rankLabel }}</strong>
|
||||
</div>
|
||||
<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.14em] text-slate-500">Votes</p>
|
||||
<strong class="mt-1 block text-xl text-slate-950">{{ leaderboardEntry.votes.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<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.14em] text-slate-500">Anteil</p>
|
||||
<strong class="mt-1 block text-xl text-violet-800">{{ leaderboardEntry.voteSharePercent }}%</strong>
|
||||
</div>
|
||||
<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.14em] text-slate-500">Viewer-Nominierungen</p>
|
||||
<strong class="mt-1 block text-xl text-slate-950">{{ leaderboardEntry.nominationTally }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mt-5 rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<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.18em] text-violet-500">Kanal</p>
|
||||
<h4 class="mt-2 text-xl font-bold text-slate-950">{{ leaderboardEntry.displayName }}</h4>
|
||||
<p class="mt-1 break-all text-sm text-slate-500">{{ leaderboardEntry.channelSlug }} · {{ leaderboardEntry.platform }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 lg:max-w-[50%] lg:justify-end">
|
||||
<span v-if="leaderboardEntry.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
|
||||
Gewinner
|
||||
</span>
|
||||
<span v-if="leaderboardEntry.isTopTie" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
|
||||
Tie
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="leaderboardEntry.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ leaderboardEntry.hasClip ? 'Clip da' : 'Clip fehlt' }}
|
||||
</span>
|
||||
<span v-if="leaderboardEntry.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
|
||||
Regelkonflikt
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="leaderboardEntry.isAccepted ? 'bg-sky-50 text-sky-700' : 'bg-slate-100 text-slate-600'">
|
||||
{{ acceptanceStatusLabel(candidate?.acceptanceStatus, leaderboardEntry.isAccepted) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mt-5 grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<section class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Voting-Readiness</p>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Annahme</p>
|
||||
<strong class="mt-1 block text-base text-slate-950">{{ acceptanceStatusLabel(candidate?.acceptanceStatus, leaderboardEntry.isAccepted) }}</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.14em] text-slate-500">Clip-Embed</p>
|
||||
<strong class="mt-1 block text-base text-slate-950">{{ clipEmbedStatusLabel(candidate?.clipEmbedStatus, leaderboardEntry.clipEmbedStatus) }}</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.14em] text-slate-500">Ø Viewer</p>
|
||||
<strong class="mt-1 block text-base text-slate-950">{{ avgViewersLabel }}</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.14em] text-slate-500">Kandidat-ID</p>
|
||||
<strong class="mt-1 block text-base text-slate-950">{{ candidate?.id ?? leaderboardEntry.candidateId }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Clip / Compilation</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Voting-Link und aktueller Nutzungsstatus.</p>
|
||||
</div>
|
||||
<a
|
||||
v-if="candidate?.clipCompilationUrl"
|
||||
:href="candidate.clipCompilationUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
|
||||
>
|
||||
Link öffnen
|
||||
</a>
|
||||
</div>
|
||||
<dl class="mt-4 grid gap-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/35 px-4 py-3">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">URL</dt>
|
||||
<dd class="mt-1 break-all text-sm text-slate-900">{{ candidate?.clipCompilationUrl || 'Kein Clip-Link gepflegt.' }}</dd>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/35 px-4 py-3">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Titel</dt>
|
||||
<dd class="mt-1 text-sm text-slate-900">{{ candidate?.clipCompilationTitle || 'Kein Titel gepflegt.' }}</dd>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/35 px-4 py-3">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Plattform</dt>
|
||||
<dd class="mt-1 text-sm text-slate-900">{{ candidate?.clipCompilationPlatform || leaderboardEntry.platform }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/35 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Teamnotiz</p>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||
{{ candidate?.acceptanceNote?.trim() || 'Noch keine Teamnotiz hinterlegt.' }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Schliessen</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AdminCandidateItem, AdminVotingCandidateRank } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
categoryName: string
|
||||
groupName: string
|
||||
rank: number | null
|
||||
candidate: AdminCandidateItem | null
|
||||
leaderboardEntry: AdminVotingCandidateRank | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const subtitle = computed(() => {
|
||||
if (!props.leaderboardEntry) return undefined
|
||||
return `${props.groupName} · ${props.categoryName}`
|
||||
})
|
||||
|
||||
const rankLabel = computed(() => props.rank ?? '–')
|
||||
const avgViewersLabel = computed(() => {
|
||||
if (props.candidate?.avgViewers === null || props.candidate?.avgViewers === undefined) return 'Keine Daten'
|
||||
return props.candidate.avgViewers.toLocaleString('de-DE')
|
||||
})
|
||||
|
||||
function acceptanceStatusLabel(status: string | null | undefined, isAccepted: boolean) {
|
||||
if (status === 'contacted') return 'Angefragt'
|
||||
if (status === 'accepted') return 'Angenommen'
|
||||
if (status === 'declined') return 'Abgesagt'
|
||||
if (status === 'open') return 'Offen'
|
||||
return isAccepted ? 'Angenommen' : 'nicht final'
|
||||
}
|
||||
|
||||
function clipEmbedStatusLabel(candidateStatus: string | null | undefined, rankStatus: string | null | undefined) {
|
||||
const value = candidateStatus || rankStatus || 'unchecked'
|
||||
if (value === 'embeddable') return 'Einbettbar'
|
||||
if (value === 'link_only') return 'Nur Link'
|
||||
if (value === 'blocked') return 'Nicht nutzbar'
|
||||
return 'Noch nicht geprueft'
|
||||
}
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
:title="category ? `${category.categoryName} Leaderboard` : 'Leaderboard'"
|
||||
:subtitle="category ? `${category.groupName} · ${viewerRangeLabel(category.viewerRangeMin, category.viewerRangeMax)}` : undefined"
|
||||
size="xl"
|
||||
@close="$emit('close')"
|
||||
@close="closeAll"
|
||||
>
|
||||
<template v-if="category">
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-hidden rounded-2xl border border-violet-100">
|
||||
<div class="hidden grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_180px] gap-3 bg-violet-50/70 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 lg:grid">
|
||||
<div class="hidden grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_240px] gap-3 bg-violet-50/70 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 lg:grid">
|
||||
<span>Rang</span>
|
||||
<span>Kandidat</span>
|
||||
<span>Votes</span>
|
||||
@@ -35,7 +35,7 @@
|
||||
<article
|
||||
v-for="(candidate, index) in category.leaderboard"
|
||||
:key="candidate.candidateId"
|
||||
class="grid gap-3 px-4 py-4 lg:grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_180px] lg:items-center"
|
||||
class="grid gap-3 px-4 py-4 lg:grid-cols-[72px_minmax(220px,1.5fr)_120px_120px_240px] lg:items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3 lg:block">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-100 text-sm font-black text-violet-800">{{ index + 1 }}</span>
|
||||
@@ -55,22 +55,28 @@
|
||||
<span class="lg:hidden text-slate-500">Anteil: </span>{{ candidate.voteSharePercent }}%
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
|
||||
Gewinner
|
||||
</span>
|
||||
<span v-if="candidate.isTopTie" class="hidden rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700 lg:inline-flex">
|
||||
Tie
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="candidate.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
|
||||
</span>
|
||||
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
|
||||
Regelkonflikt
|
||||
</span>
|
||||
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
||||
nicht final
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:flex-nowrap lg:justify-between">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-if="candidate.isCurrentWinner" class="rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-semibold text-emerald-700">
|
||||
Gewinner
|
||||
</span>
|
||||
<span v-if="candidate.isTopTie" class="hidden rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700 lg:inline-flex">
|
||||
Tie
|
||||
</span>
|
||||
<span class="rounded-full px-3 py-1 text-[11px] font-semibold" :class="candidate.hasClip ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ candidate.hasClip ? 'Clip da' : 'Clip fehlt' }}
|
||||
</span>
|
||||
<span v-if="candidate.hasWinnerConflict" class="rounded-full bg-amber-50 px-3 py-1 text-[11px] font-semibold text-amber-700">
|
||||
Regelkonflikt
|
||||
</span>
|
||||
<span v-if="!candidate.isAccepted" class="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-semibold text-slate-600">
|
||||
nicht final
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="gap-1.5 whitespace-nowrap" @click="openCandidateDetails(candidate.candidateId)">
|
||||
<Eye class="h-4 w-4" />
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -81,20 +87,66 @@
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<AdminVotingCandidateDetailModal
|
||||
:open="selectedCandidateId !== null"
|
||||
:category-name="category?.categoryName ?? ''"
|
||||
:group-name="category?.groupName ?? ''"
|
||||
:rank="selectedCandidateRankIndex"
|
||||
:candidate="selectedCandidate"
|
||||
:leaderboard-entry="selectedCandidateRank"
|
||||
@close="selectedCandidateId = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AdminVotingWorkspaceRow } from './useAdminVotingManager'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Eye } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
import AdminVotingCandidateDetailModal from './AdminVotingCandidateDetailModal.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminVotingWorkspaceRow } from './useAdminVotingManager'
|
||||
|
||||
const props = defineProps<{
|
||||
category: AdminVotingWorkspaceRow | null
|
||||
candidates: AdminCandidateItem[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const selectedCandidateId = ref<number | null>(null)
|
||||
|
||||
const selectedCandidate = computed(() =>
|
||||
props.candidates.find((candidate) => candidate.id === selectedCandidateId.value) ?? null,
|
||||
)
|
||||
|
||||
const selectedCandidateRank = computed(() =>
|
||||
props.category?.leaderboard.find((candidate) => candidate.candidateId === selectedCandidateId.value) ?? null,
|
||||
)
|
||||
|
||||
const selectedCandidateRankIndex = computed(() => {
|
||||
if (!props.category || selectedCandidateId.value === null) return null
|
||||
const index = props.category.leaderboard.findIndex((candidate) => candidate.candidateId === selectedCandidateId.value)
|
||||
return index >= 0 ? index + 1 : null
|
||||
})
|
||||
|
||||
watch(() => props.category?.categoryId ?? null, () => {
|
||||
selectedCandidateId.value = null
|
||||
})
|
||||
|
||||
function openCandidateDetails(candidateId: number) {
|
||||
selectedCandidateId.value = candidateId
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
selectedCandidateId.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function viewerRangeLabel(min: number | null, max: number | null) {
|
||||
if (min === null && max === null) return 'Offene Einordnung'
|
||||
if (min !== null && max === null) return `Ab ${min} Viewer`
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{{ group.winnerSetCount }} Gewinner
|
||||
</span>
|
||||
<span v-if="group.problemCount > 0" class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
{{ group.problemCount }} pruefen
|
||||
{{ group.problemCount }} prüfen
|
||||
</span>
|
||||
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700">
|
||||
<ChevronDown class="h-4 w-4 transition" :class="isExpanded(group.name) ? 'rotate-180' : ''" />
|
||||
|
||||
@@ -259,7 +259,7 @@ function buildWinnerOptions(row: AdminWinnerResultRow) {
|
||||
})
|
||||
|
||||
return [
|
||||
{ label: 'Bitte Gewinner waehlen', value: '' },
|
||||
{ label: 'Bitte Gewinner wählen', value: '' },
|
||||
...sortedCandidates.map((candidate) => {
|
||||
const rank = rankByCandidateId.get(candidate.id)
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,8 @@ export type FaqFormItem = {
|
||||
export type AdminContentForm = {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
|
||||
@@ -46,7 +46,7 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
|
||||
{
|
||||
key: 'nomination',
|
||||
title: 'Nominierung',
|
||||
description: 'Community schlägt Creator und Clips vor.',
|
||||
description: 'Community reicht Creator-Vorschläge ein.',
|
||||
start: 'nominationStartsAt',
|
||||
end: 'nominationEndsAt',
|
||||
editable: true,
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useAdminAnalyticsManager() {
|
||||
{
|
||||
label: 'Votes pro Kandidat',
|
||||
value: votesPerCandidate.toLocaleString('de-DE'),
|
||||
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
|
||||
note: 'Zeigt, ob die Kandidatenbasis breit genug für die aktuelle Vote-Menge ist.',
|
||||
icon: Vote,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { AdminArchivedWinnerItem, UpsertArchivedWinnerPayload } from '../../types/awards'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
interface ArchiveDraft {
|
||||
localKey: string
|
||||
id: number | null
|
||||
year: string
|
||||
category: string
|
||||
subcategory: string
|
||||
winnerName: string
|
||||
winnerUrl: string
|
||||
updatedAt: string | null
|
||||
isNew: boolean
|
||||
isSaving: boolean
|
||||
isDeleting: boolean
|
||||
}
|
||||
|
||||
let draftCounter = 0
|
||||
|
||||
export function useAdminArchiveManager() {
|
||||
const store = useAwardsStore()
|
||||
const loading = ref(false)
|
||||
const adminError = ref('')
|
||||
const adminMessage = ref('')
|
||||
const drafts = ref<ArchiveDraft[]>([])
|
||||
|
||||
const stats = computed(() => {
|
||||
const savedDrafts = drafts.value.filter((draft) => draft.id !== null)
|
||||
const years = new Set(savedDrafts.map((draft) => draft.year).filter((year) => year.trim().length > 0))
|
||||
const unsaved = drafts.value.filter((draft) => draft.isNew).length
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Archiv-Einträge',
|
||||
value: `${savedDrafts.length}`,
|
||||
note: 'Gespeicherte Gewinner für die Landingpage',
|
||||
},
|
||||
{
|
||||
label: 'Jahre',
|
||||
value: `${years.size}`,
|
||||
note: 'Unabhängig vom aktiven Award-Jahr',
|
||||
},
|
||||
{
|
||||
label: 'Neue Zeilen',
|
||||
value: `${unsaved}`,
|
||||
note: unsaved > 0 ? 'Noch nicht gespeichert' : 'Alles ist gespeichert',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const groupedDrafts = computed(() => {
|
||||
const groups = new Map<string, { key: string; label: string; sortYear: number; items: ArchiveDraft[] }>()
|
||||
|
||||
for (const draft of [...drafts.value].sort(compareDrafts)) {
|
||||
const numericYear = Number.parseInt(draft.year, 10)
|
||||
const isValidYear = !draft.isNew && Number.isFinite(numericYear)
|
||||
const key = isValidYear ? `year-${numericYear}` : 'new'
|
||||
const existing = groups.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.items.push(draft)
|
||||
continue
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
key,
|
||||
label: isValidYear ? String(numericYear) : 'Neu',
|
||||
sortYear: isValidYear ? numericYear : Number.MAX_SAFE_INTEGER,
|
||||
items: [draft],
|
||||
})
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.sort((left, right) => right.sortYear - left.sortYear)
|
||||
})
|
||||
|
||||
async function loadArchivedWinners() {
|
||||
loading.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
const entries = await store.loadAdminArchivedWinners()
|
||||
drafts.value = entries.map(createDraftFromItem)
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Das Archiv konnte nicht geladen werden.'
|
||||
drafts.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addDraft() {
|
||||
drafts.value.unshift(createEmptyDraft())
|
||||
}
|
||||
|
||||
async function saveDraft(localKey: string) {
|
||||
const draft = drafts.value.find((item) => item.localKey === localKey)
|
||||
if (!draft) return
|
||||
|
||||
adminError.value = ''
|
||||
adminMessage.value = ''
|
||||
|
||||
let payload: UpsertArchivedWinnerPayload
|
||||
try {
|
||||
payload = buildPayload(draft)
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Bitte alle Pflichtfelder prüfen.'
|
||||
return
|
||||
}
|
||||
|
||||
draft.isSaving = true
|
||||
try {
|
||||
if (draft.id === null) {
|
||||
await store.createAdminArchivedWinner(payload)
|
||||
adminMessage.value = 'Archiv-Eintrag wurde angelegt.'
|
||||
} else {
|
||||
await store.updateAdminArchivedWinner(draft.id, payload)
|
||||
adminMessage.value = 'Archiv-Eintrag wurde gespeichert.'
|
||||
}
|
||||
|
||||
drafts.value = store.adminArchivedWinners.map(createDraftFromItem)
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Der Archiv-Eintrag konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
draft.isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDraft(localKey: string) {
|
||||
const draft = drafts.value.find((item) => item.localKey === localKey)
|
||||
if (!draft) return
|
||||
|
||||
adminError.value = ''
|
||||
adminMessage.value = ''
|
||||
|
||||
if (draft.id === null) {
|
||||
drafts.value = drafts.value.filter((item) => item.localKey !== localKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !window.confirm(`Archiv-Eintrag für ${draft.winnerName || draft.subcategory} wirklich löschen?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
draft.isDeleting = true
|
||||
try {
|
||||
await store.deleteAdminArchivedWinner(draft.id)
|
||||
drafts.value = store.adminArchivedWinners.map(createDraftFromItem)
|
||||
adminMessage.value = 'Archiv-Eintrag wurde gelöscht.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Der Archiv-Eintrag konnte nicht gelöscht werden.'
|
||||
} finally {
|
||||
draft.isDeleting = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
adminError,
|
||||
adminMessage,
|
||||
drafts,
|
||||
groupedDrafts,
|
||||
stats,
|
||||
loadArchivedWinners,
|
||||
addDraft,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
}
|
||||
}
|
||||
|
||||
function createDraftFromItem(item: AdminArchivedWinnerItem): ArchiveDraft {
|
||||
return {
|
||||
localKey: `saved-${item.id}`,
|
||||
id: item.id,
|
||||
year: String(item.year),
|
||||
category: item.category,
|
||||
subcategory: item.subcategory,
|
||||
winnerName: item.winnerName,
|
||||
winnerUrl: item.winnerUrl,
|
||||
updatedAt: item.updatedAt,
|
||||
isNew: false,
|
||||
isSaving: false,
|
||||
isDeleting: false,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyDraft(): ArchiveDraft {
|
||||
draftCounter += 1
|
||||
return {
|
||||
localKey: `new-${draftCounter}`,
|
||||
id: null,
|
||||
year: '',
|
||||
category: '',
|
||||
subcategory: '',
|
||||
winnerName: '',
|
||||
winnerUrl: '',
|
||||
updatedAt: null,
|
||||
isNew: true,
|
||||
isSaving: false,
|
||||
isDeleting: false,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(draft: ArchiveDraft): UpsertArchivedWinnerPayload {
|
||||
const year = Number.parseInt(draft.year.trim(), 10)
|
||||
if (!Number.isFinite(year)) {
|
||||
throw new Error('Bitte ein gültiges Jahr eintragen.')
|
||||
}
|
||||
|
||||
const category = draft.category.trim()
|
||||
const subcategory = draft.subcategory.trim()
|
||||
const winnerName = draft.winnerName.trim()
|
||||
const winnerUrl = draft.winnerUrl.trim()
|
||||
|
||||
if (!category || !subcategory || !winnerName || !winnerUrl) {
|
||||
throw new Error('Jahr, Gewinner, Link, Kategorie und Unterkategorie sind Pflichtfelder.')
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
category,
|
||||
subcategory,
|
||||
winnerName,
|
||||
winnerUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function compareDrafts(left: ArchiveDraft, right: ArchiveDraft) {
|
||||
const leftYear = Number.parseInt(left.year, 10)
|
||||
const rightYear = Number.parseInt(right.year, 10)
|
||||
const normalizedLeftYear = Number.isFinite(leftYear) ? leftYear : Number.MAX_SAFE_INTEGER
|
||||
const normalizedRightYear = Number.isFinite(rightYear) ? rightYear : Number.MAX_SAFE_INTEGER
|
||||
|
||||
if (normalizedLeftYear !== normalizedRightYear) {
|
||||
return normalizedRightYear - normalizedLeftYear
|
||||
}
|
||||
|
||||
return [
|
||||
left.category,
|
||||
left.subcategory,
|
||||
left.winnerName,
|
||||
].join('\u0000').localeCompare([
|
||||
right.category,
|
||||
right.subcategory,
|
||||
right.winnerName,
|
||||
].join('\u0000'), 'de')
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { api } from '../../lib/api'
|
||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
||||
import type { AdminCandidateDeletePreview, AdminCandidateItem, AdminWorkflowRule } from '../../types/awards'
|
||||
|
||||
interface CandidateForm {
|
||||
categoryId: number
|
||||
@@ -64,6 +65,8 @@ export function useAdminCandidateManager() {
|
||||
const editingId = ref<number | 'new' | null>(null)
|
||||
const openedRouteCandidateId = ref<number | null>(null)
|
||||
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
||||
const deletePreviewLoading = ref(false)
|
||||
const candidateDeletePreview = ref<AdminCandidateDeletePreview | null>(null)
|
||||
const form = reactive<CandidateForm>({
|
||||
categoryId: 0,
|
||||
displayName: '',
|
||||
@@ -392,7 +395,7 @@ export function useAdminCandidateManager() {
|
||||
try {
|
||||
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.`
|
||||
candidateToDelete.value = null
|
||||
closeDeleteModal()
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
@@ -400,6 +403,27 @@ export function useAdminCandidateManager() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDelete(candidate: AdminCandidateItem) {
|
||||
candidateToDelete.value = candidate
|
||||
candidateDeletePreview.value = null
|
||||
deletePreviewLoading.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
candidateDeletePreview.value = await api.getAdminCandidateDeletePreview(candidate.id)
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschvorschau konnte nicht geladen werden.'
|
||||
} finally {
|
||||
deletePreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
candidateToDelete.value = null
|
||||
candidateDeletePreview.value = null
|
||||
deletePreviewLoading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
seasonDetail,
|
||||
saving,
|
||||
@@ -437,9 +461,13 @@ export function useAdminCandidateManager() {
|
||||
clipEmbedStatusOptions,
|
||||
readinessFilterOptions,
|
||||
candidateToDelete,
|
||||
deletePreviewLoading,
|
||||
candidateDeletePreview,
|
||||
clearFilters,
|
||||
openCreate,
|
||||
openEdit,
|
||||
openDelete,
|
||||
closeDeleteModal,
|
||||
handlePlatformSelection,
|
||||
saveModal,
|
||||
confirmDelete,
|
||||
|
||||
@@ -217,7 +217,7 @@ export function useAdminCategoryManager() {
|
||||
editingGroupName.value === null ? 'Hauptkategorie anlegen' : 'Hauptkategorie bearbeiten',
|
||||
)
|
||||
const subcategoryModalTitle = computed(() =>
|
||||
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufuegen' : 'Unterkategorie bearbeiten',
|
||||
editingSubcategoryIndex.value === null ? 'Unterkategorie hinzufügen' : 'Unterkategorie bearbeiten',
|
||||
)
|
||||
|
||||
const overviewCategories = computed(() => {
|
||||
@@ -390,7 +390,7 @@ export function useAdminCategoryManager() {
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((draft, index) => ({ ...draft, sortOrder: index + 1 })),
|
||||
})
|
||||
adminMessage.value = 'Unterkategorien wurden fuer alle Hauptkategorien aktualisiert.'
|
||||
adminMessage.value = 'Unterkategorien wurden für alle Hauptkategorien aktualisiert.'
|
||||
closeSubcategoryConfigModal()
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Unterkategorien konnten nicht gespeichert werden.'
|
||||
|
||||
@@ -30,6 +30,8 @@ function createEmptyForm(): AdminContentForm {
|
||||
return {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
hostArtistName: '',
|
||||
hostImageUrl: '/assets/amaterasu2sei_2.png',
|
||||
newsletterUrl: '',
|
||||
shareXUrl: '',
|
||||
shareDiscordUrl: '',
|
||||
@@ -43,7 +45,7 @@ function createEmptyForm(): AdminContentForm {
|
||||
sponsorsContent: '',
|
||||
showactsUrl: '',
|
||||
showactsContent: '',
|
||||
streamBannerEyebrow: 'Das grosse Finale',
|
||||
streamBannerEyebrow: 'Das große Finale',
|
||||
streamBannerTitle: 'Award-Show Finale',
|
||||
streamBannerText: 'Finale, Countdown und Stream-Link an einem Ort. Sei live dabei, wenn die Community ihre Stars feiert.',
|
||||
streamBannerLiveButtonLabel: 'Jetzt live · Zum Stream',
|
||||
@@ -52,7 +54,7 @@ function createEmptyForm(): AdminContentForm {
|
||||
streamBannerUseCompletedContent: false,
|
||||
streamBannerCompletedEyebrow: 'Danke fürs Mitfiebern',
|
||||
streamBannerCompletedTitle: 'Award-Show abgeschlossen',
|
||||
streamBannerCompletedText: 'Die grosse Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
|
||||
streamBannerCompletedText: 'Die große Award-Show ist vorbei. Danke an alle, die nominiert, gevotet und live mitgefiebert haben.',
|
||||
streamBannerCompletedButtonLabel: 'Highlights ansehen',
|
||||
streamBannerCompletedButtonUrl: '',
|
||||
awardsSectionTitle: '',
|
||||
@@ -106,12 +108,16 @@ export function useAdminContentManager() {
|
||||
const saveError = ref('')
|
||||
const privacyPreviewOpen = ref(false)
|
||||
const iconUploadError = ref('')
|
||||
const hostImageUploadError = ref('')
|
||||
const hostImageUploading = ref(false)
|
||||
|
||||
watch(
|
||||
() => store.adminSiteSettings,
|
||||
(settings) => {
|
||||
form.hostDisplayName = settings.hostDisplayName
|
||||
form.hostTagline = settings.hostTagline
|
||||
form.hostArtistName = settings.hostArtistName
|
||||
form.hostImageUrl = settings.hostImageUrl
|
||||
form.newsletterUrl = settings.newsletterUrl
|
||||
form.shareXUrl = settings.shareXUrl
|
||||
form.shareDiscordUrl = settings.shareDiscordUrl
|
||||
@@ -290,6 +296,40 @@ export function useAdminContentManager() {
|
||||
form.socialLinks[index].icon = ''
|
||||
}
|
||||
|
||||
async function handleHostImageUpload(event: Event) {
|
||||
hostImageUploadError.value = ''
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
hostImageUploadError.value = 'Bitte PNG, JPG oder WebP hochladen.'
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = 8 * 1024 * 1024
|
||||
if (file.size > maxBytes) {
|
||||
hostImageUploadError.value = 'Hostbild ist zu groß. Maximal erlaubt sind 8 MB.'
|
||||
return
|
||||
}
|
||||
|
||||
hostImageUploading.value = true
|
||||
try {
|
||||
await store.uploadAdminHostImage(file)
|
||||
saveMessage.value = 'Hostbild gespeichert und auf der Landingpage aktualisiert.'
|
||||
} catch (error) {
|
||||
hostImageUploadError.value = error instanceof Error ? error.message : 'Hostbild konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
hostImageUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSiteSettings(sectionLabel = 'Landingpage-Inhalte') {
|
||||
saving.value = true
|
||||
saveMessage.value = ''
|
||||
@@ -299,6 +339,7 @@ export function useAdminContentManager() {
|
||||
await store.updateAdminSiteSettings({
|
||||
hostDisplayName: form.hostDisplayName,
|
||||
hostTagline: form.hostTagline,
|
||||
hostArtistName: form.hostArtistName,
|
||||
newsletterUrl: form.newsletterUrl,
|
||||
shareXUrl: form.shareXUrl,
|
||||
shareDiscordUrl: form.shareDiscordUrl,
|
||||
@@ -361,6 +402,8 @@ export function useAdminContentManager() {
|
||||
saveError,
|
||||
privacyPreviewOpen,
|
||||
iconUploadError,
|
||||
hostImageUploadError,
|
||||
hostImageUploading,
|
||||
privacyPreviewHtml,
|
||||
privacyUpdatedLabel,
|
||||
addSocialLink,
|
||||
@@ -376,6 +419,7 @@ export function useAdminContentManager() {
|
||||
socialSimpleIconColor,
|
||||
handleSocialIconUpload,
|
||||
clearSocialIcon,
|
||||
handleHostImageUpload,
|
||||
saveSiteSettings,
|
||||
adminSiteSettings: computed(() => store.adminSiteSettings),
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ export function useAdminDashboardOverview() {
|
||||
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
|
||||
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
|
||||
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
|
||||
if (path.startsWith('/admin/archive')) return authStore.hasPermission('winners')
|
||||
if (path.startsWith('/admin/clips')) return authStore.hasPermission('clips')
|
||||
if (path.startsWith('/admin/users-logs')) return authStore.hasPermission('audit')
|
||||
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
|
||||
|
||||
@@ -139,9 +139,6 @@ export function useAdminReviewsManager(options?: {
|
||||
? (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)
|
||||
@@ -153,7 +150,6 @@ export function useAdminReviewsManager(options?: {
|
||||
if (!selectedNomination.value) return false
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim() && form.categoryId)
|
||||
&& !(selectedNominationHasBlockingFlag.value && selectedNomination.value.trackingReviewStatus !== 'overridden')
|
||||
})
|
||||
|
||||
function focusNominationFromRoute() {
|
||||
@@ -444,7 +440,6 @@ export function useAdminReviewsManager(options?: {
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
trackingReviewNotes,
|
||||
selectedNominationHasBlockingFlag,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
|
||||
@@ -119,7 +119,7 @@ export function useAdminSeasonManager() {
|
||||
)
|
||||
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
|
||||
const latestSeasonAuditSummary = computed(() =>
|
||||
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion fuer dieses Jahr gefunden.',
|
||||
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion für dieses Jahr gefunden.',
|
||||
)
|
||||
const latestSeasonAuditMeta = computed(() => {
|
||||
const entry = latestSeasonAuditEntry.value
|
||||
@@ -530,7 +530,7 @@ function buildReadinessItems(
|
||||
? 'Alle Kategorien haben Kandidaten.'
|
||||
: candidateBlocking
|
||||
? `${facts.emptyCategories} Kategorien brauchen noch Kandidaten.`
|
||||
: 'In der Nominierung duerfen Kategorien noch leer sein.',
|
||||
: 'In der Nominierung dürfen Kategorien noch leer sein.',
|
||||
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && activeCandidateCount > 0,
|
||||
blocking: candidateBlocking,
|
||||
to: '/admin/candidates',
|
||||
@@ -559,7 +559,7 @@ function buildReadinessItems(
|
||||
? 'Alle Gewinner sind vergeben.'
|
||||
: winnerBlocking
|
||||
? `${facts.missingResults} Kategorien brauchen vor Abschluss einen Gewinner.`
|
||||
: 'Vor Abschluss muessen alle Gewinner gesetzt sein.',
|
||||
: 'Vor Abschluss müssen alle Gewinner gesetzt sein.',
|
||||
complete: detail.categories.length > 0 && facts.missingResults === 0,
|
||||
blocking: winnerBlocking,
|
||||
to: '/admin/analytics',
|
||||
@@ -606,7 +606,7 @@ function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
|
||||
}
|
||||
|
||||
if (phaseKey !== 'nomination') {
|
||||
issues.push('Direkt public ist fuer neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
|
||||
issues.push('Direkt public ist für neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
|
||||
}
|
||||
|
||||
return issues
|
||||
|
||||
@@ -57,7 +57,7 @@ function normalizeFlagRule(rule: AdminTrackingFlagRule): AdminTrackingFlagRule {
|
||||
enabled: Boolean(rule.enabled),
|
||||
autoTriggerEnabled: Boolean(rule.autoTriggerEnabled),
|
||||
requiresManualReview: Boolean(rule.requiresManualReview),
|
||||
blocksApproval: Boolean(rule.blocksApproval),
|
||||
blocksApproval: false,
|
||||
adminNoteRequiredOnOverride: Boolean(rule.adminNoteRequiredOnOverride),
|
||||
severity: rule.severity === 'high' || rule.severity === 'low' ? rule.severity : 'medium',
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function useAdminVotingManager() {
|
||||
const focusedCategoryId = ref<number | null>(null)
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const seasonCandidates = computed(() => seasonDetail.value.candidates)
|
||||
const votingWorkspace = computed(() => seasonDetail.value.votingWorkspace)
|
||||
const readinessScope = computed(() => buildAdminReadinessScope(seasonDetail.value))
|
||||
|
||||
@@ -73,7 +74,7 @@ export function useAdminVotingManager() {
|
||||
statusTone = 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
} else if (isProblem) {
|
||||
statusKey = 'issues'
|
||||
statusLabel = 'Pruefen'
|
||||
statusLabel = 'Prüfen'
|
||||
statusTone = 'border-amber-100 bg-amber-50 text-amber-700'
|
||||
} else if (category.winnerReady) {
|
||||
statusKey = 'ready'
|
||||
@@ -192,7 +193,7 @@ export function useAdminVotingManager() {
|
||||
{
|
||||
label: 'Vorbereitung',
|
||||
value: String(votingWorkspace.value.summary.readySubcategories),
|
||||
note: 'Unterkategorien sind fuer Gewinner vorbereitet',
|
||||
note: 'Unterkategorien sind für Gewinner vorbereitet',
|
||||
icon: Trophy,
|
||||
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
@@ -213,7 +214,7 @@ export function useAdminVotingManager() {
|
||||
|
||||
const statusFilters = computed(() => [
|
||||
{ value: allFilter, label: 'Alle', count: workspaceRows.value.length },
|
||||
{ value: 'issues', label: 'Pruefen', count: workspaceRows.value.filter((row) => row.isProblem).length },
|
||||
{ value: 'issues', label: 'Prüfen', count: workspaceRows.value.filter((row) => row.isProblem).length },
|
||||
{ value: 'ready', label: 'Bereit', count: workspaceRows.value.filter((row) => row.winnerReady).length },
|
||||
{ value: 'winner', label: 'Gewinner gesetzt', count: workspaceRows.value.filter((row) => row.hasWinner).length },
|
||||
{ value: 'idle', label: 'Noch ohne Votes', count: workspaceRows.value.filter((row) => row.voteCount === 0).length },
|
||||
@@ -281,6 +282,7 @@ export function useAdminVotingManager() {
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
seasonCandidates,
|
||||
votingGroups,
|
||||
selectedGroup,
|
||||
selectedGroupName,
|
||||
|
||||
@@ -471,7 +471,7 @@ export function useAdminWinnersManager() {
|
||||
{
|
||||
label: 'Reviews',
|
||||
value: String(reviewWarningCount.value),
|
||||
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
|
||||
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe prüfen.',
|
||||
icon: Clock3,
|
||||
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
progress: null,
|
||||
|
||||
@@ -59,7 +59,7 @@ function initialsFor(value: string) {
|
||||
<component
|
||||
:is="winner.url ? 'a' : 'article'"
|
||||
v-for="winner in props.selectedArchive.winners"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}-${winner.subcategory}-${winner.name}`"
|
||||
:href="winner.url || undefined"
|
||||
:target="winner.url ? '_blank' : undefined"
|
||||
:rel="winner.url ? 'noopener' : undefined"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<span style="position:absolute;top:230px;left:62%;width:8px;height:8px;border-radius:50%;background:#c9b6f0;opacity:.7;"></span>
|
||||
<span style="position:absolute;bottom:120px;left:84%;font-size:14px;color:#e7b13e;animation:twinkle 3.1s ease-in-out 1.4s infinite;">✦</span>
|
||||
|
||||
<img class="home-hero__character" src="/assets/jayu-hero.png" alt="Jayu mit Stern-Pokal" data-hero-char style="position:absolute;top:-10px;right:max(30px,calc(50% - 560px));height:1010px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);" />
|
||||
<img class="home-hero__character" :src="siteContent.hostImageUrl || '/assets/amaterasu2sei_2.png'" alt="Host der VTuber Star Awards" data-hero-char style="position:absolute;top:16px;right:max(-70px,calc(50% - 660px));height:1040px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 52%,rgba(0,0,0,0) 83%);mask-image:linear-gradient(to bottom,#000 52%,rgba(0,0,0,0) 83%);" />
|
||||
<div class="home-hero__veil" style="position:absolute;inset:0;z-index:2;pointer-events:none;background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.86) 26%,rgba(246,240,254,.3) 46%,transparent 62%);"></div>
|
||||
|
||||
<div class="home-hero__content" style="position:relative;z-index:3;max-width:1200px;margin:0 auto;padding:60px 24px 70px;">
|
||||
@@ -95,6 +95,10 @@
|
||||
<div style="font-size:12px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:5px;">Host</div>
|
||||
<div class="home-hero__host-name" style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-weight:700;font-size:27px;letter-spacing:.5px;color:#5f44ad;line-height:1;margin-bottom:5px;">{{ siteContent.hostDisplayName.toUpperCase() }} <span style="font-size:19px;color:#e7b13e;">✦</span></div>
|
||||
<div style="font-size:14px;color:#8a8398;margin-bottom:14px;">{{ siteContent.hostTagline }}</div>
|
||||
<div v-if="siteContent.hostArtistName" style="display:inline-flex;align-items:center;gap:7px;margin:-2px 0 14px;padding:7px 10px;border-radius:999px;background:rgba(241,236,251,.74);font-size:12px;font-weight:700;letter-spacing:.04em;color:#7b68ad;">
|
||||
<span style="color:#e7b13e;">✧</span>
|
||||
Art by {{ siteContent.hostArtistName }}
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<a
|
||||
v-for="social in hostSocialLinks"
|
||||
@@ -138,6 +142,8 @@ interface HomeSocialLink {
|
||||
interface HomeSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -14,6 +14,8 @@ interface HomeSocialLink {
|
||||
interface HomeSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -56,7 +56,6 @@ const nominationCatValue = ref(0)
|
||||
const clipCatValue = ref(0)
|
||||
const clipNomQuery = ref('')
|
||||
const nominationDrafts = ref<Record<number, string[]>>({})
|
||||
const nominationVisibleLinkCounts = ref<Record<number, number>>({})
|
||||
const nominationReviewMode = ref(false)
|
||||
|
||||
watch(
|
||||
@@ -99,39 +98,25 @@ function notifyClipCategoryChange(value: number) {
|
||||
|
||||
function syncNominationDrafts(options: HomeSelectionOption[]) {
|
||||
const nextDrafts: Record<number, string[]> = {}
|
||||
const nextVisibleLinkCounts: Record<number, number> = {}
|
||||
for (const option of options) {
|
||||
const existing = nominationDrafts.value[option.id] ?? []
|
||||
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(limit, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
|
||||
}
|
||||
nominationDrafts.value = nextDrafts
|
||||
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
|
||||
}
|
||||
|
||||
function nominationLinksFor(categoryIndex: number) {
|
||||
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
|
||||
}
|
||||
|
||||
function nominationVisibleLinkCountFor(categoryIndex: number) {
|
||||
return nominationVisibleLinkCounts.value[categoryIndex] ?? 1
|
||||
}
|
||||
|
||||
function compactNominationCategory(categoryIndex: number) {
|
||||
const limit = nominationLinkLimitFor(categoryIndex)
|
||||
const links = nominationLinksFor(categoryIndex)
|
||||
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,
|
||||
[categoryIndex]: nextLinks,
|
||||
}
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[categoryIndex]: nextVisibleCount,
|
||||
[categoryIndex]: createNominationLinkSlots(compactedLinks, limit),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,9 +148,6 @@ const activeNominationCategory = computed(() =>
|
||||
)
|
||||
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
|
||||
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
|
||||
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
|
||||
const activeNominationLinkLimit = computed(() => nominationLinkLimitFor(nominationCatValue.value))
|
||||
const activeNominationRemainingLinks = computed(() => Math.max(0, activeNominationLinkLimit.value - activeNominationVisibleLinkCount.value))
|
||||
const nominationReviewItems = computed(() =>
|
||||
props.catOptions
|
||||
.map((option) => ({
|
||||
@@ -239,36 +221,6 @@ function editNominationCategory(categoryIndex: number) {
|
||||
nominationReviewMode.value = false
|
||||
}
|
||||
|
||||
function addNominationLinkField() {
|
||||
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[nominationCatValue.value]: Math.min(limit, currentCount + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function removeNominationLinkField(index: number) {
|
||||
if (index <= 0) return
|
||||
const compactedLinks = nominationLinksFor(nominationCatValue.value)
|
||||
.filter((_, linkIndex) => linkIndex !== index)
|
||||
.map((link) => link.trim())
|
||||
.filter(Boolean)
|
||||
.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(limit, Math.max(previousVisibleCount - 1, compactedLinks.length)))
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
[nominationCatValue.value]: nextLinks,
|
||||
}
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[nominationCatValue.value]: nextVisibleCount,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNominationDrafts() {
|
||||
if (nominationHasErrors.value || nominationTotalLinks.value === 0 || props.submitting) return
|
||||
await props.submitNomination({
|
||||
@@ -371,11 +323,7 @@ function stripCategoryIcon(value: string) {
|
||||
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||
:links="activeNominationLinks"
|
||||
:errors="activeNominationErrors"
|
||||
:visible-link-count="activeNominationVisibleLinkCount"
|
||||
:remaining-link-count="activeNominationRemainingLinks"
|
||||
:on-link-input="onNominationLinkInput"
|
||||
:on-add-link-field="addNominationLinkField"
|
||||
:on-remove-link-field="removeNominationLinkField"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,7 @@ const props = defineProps<{
|
||||
categoryName: string
|
||||
links: string[]
|
||||
errors: string[]
|
||||
visibleLinkCount: number
|
||||
remainingLinkCount: number
|
||||
onLinkInput: (index: number, value: string) => void
|
||||
onAddLinkField: () => void
|
||||
onRemoveLinkField: (index: number) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -20,17 +16,9 @@ const props = defineProps<{
|
||||
</header>
|
||||
|
||||
<div class="home-nomination-pane__fields">
|
||||
<label v-for="(_, index) in props.links.slice(0, props.visibleLinkCount)" :key="index" class="home-nomination-field">
|
||||
<label v-for="(_, index) in props.links" :key="index" class="home-nomination-field">
|
||||
<span class="home-nomination-field__header">
|
||||
<span>{{ index === 0 ? 'Link 1' : `Link ${index + 1} optional` }}</span>
|
||||
<button
|
||||
v-if="index > 0"
|
||||
type="button"
|
||||
class="home-nomination-field__remove"
|
||||
@click.prevent="props.onRemoveLinkField(index)"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:value="props.links[index]"
|
||||
@@ -44,16 +32,5 @@ const props = defineProps<{
|
||||
<small v-else>Offizieller Kanal- oder Stream-Link.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="props.remainingLinkCount > 0"
|
||||
type="button"
|
||||
class="home-nomination-pane__add-link"
|
||||
@click="props.onAddLinkField"
|
||||
>
|
||||
<span>+ Weiteren Link hinzufügen</span>
|
||||
<small>Noch {{ props.remainingLinkCount }} möglich</small>
|
||||
</button>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,12 @@ const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.va
|
||||
const safeNewsletterUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
|
||||
)
|
||||
const safeShareXUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.shareXUrl) ? props.siteContent.shareXUrl : '',
|
||||
)
|
||||
const safeShareDiscordUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.shareDiscordUrl) ? props.siteContent.shareDiscordUrl : '',
|
||||
)
|
||||
const isSponsorFooterModal = computed(() => activeFooterLink.value?.key === 'sponsors')
|
||||
const footerModalEyebrow = computed(() =>
|
||||
isSponsorFooterModal.value ? 'Support the show' : 'Footer Seite',
|
||||
@@ -164,8 +170,8 @@ onUnmounted(() => {
|
||||
<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 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 v-if="safeShareXUrl" :href="safeShareXUrl" 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="safeShareDiscordUrl" :href="safeShareDiscordUrl" 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>
|
||||
|
||||
@@ -703,47 +703,6 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
font-weight:800;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link{
|
||||
width:100%;
|
||||
margin-top:14px;
|
||||
padding:13px 16px;
|
||||
border:1px dashed #cdbcf0;
|
||||
border-radius:16px;
|
||||
background:linear-gradient(135deg,rgba(255,255,255,.9),rgba(246,240,254,.88));
|
||||
color:#7658c4;
|
||||
cursor:pointer;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
font-family:'Outfit',sans-serif;
|
||||
text-align:left;
|
||||
transition:transform .16s ease,border-color .16s ease,box-shadow .16s ease,background .16s ease;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link:hover{
|
||||
border-color:#a98ddb;
|
||||
background:#fff;
|
||||
box-shadow:0 12px 28px rgba(82,61,128,.09);
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link span{
|
||||
font-size:14px;
|
||||
font-weight:900;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link small{
|
||||
flex:none;
|
||||
padding:4px 8px;
|
||||
border-radius:999px;
|
||||
background:#f0e8fb;
|
||||
color:#7d60c6;
|
||||
font-size:11px;
|
||||
font-weight:900;
|
||||
line-height:1;
|
||||
}
|
||||
|
||||
.home-nomination-review__hero h4,
|
||||
.home-missing-votes h4{
|
||||
margin:0 0 8px;
|
||||
@@ -2249,11 +2208,6 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
min-height:0!important;
|
||||
}
|
||||
|
||||
.home-nomination-pane__add-link{
|
||||
align-items:flex-start!important;
|
||||
flex-direction:column!important;
|
||||
}
|
||||
|
||||
.home-nomination-review__stats{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AdminAuditEntriesResponse,
|
||||
AdminArchivedWinnerItem,
|
||||
AdminAuditQueryOptions,
|
||||
AdminCandidateDeletePreview,
|
||||
AdminDashboardResponse,
|
||||
AdminOptionalFeatureSettingsResponse,
|
||||
AdminOperationalSettingsResponse,
|
||||
@@ -43,6 +45,7 @@ import type {
|
||||
UpdateTeamRolesPayload,
|
||||
UpdateWorkflowRulesPayload,
|
||||
UpsertCandidatePayload,
|
||||
UpsertArchivedWinnerPayload,
|
||||
UpsertCategoryPayload,
|
||||
UpsertCategoryGroupPayload,
|
||||
UpsertSponsorPayload,
|
||||
@@ -139,7 +142,25 @@ export const adminApi = {
|
||||
requestJson<{ saved: boolean; sponsor: AdminSponsorItem }>(`/api/admin/sponsors/${sponsorId}`, jsonRequest('PUT', payload)),
|
||||
deleteAdminSponsor: (sponsorId: number) =>
|
||||
requestJson<{ deleted: boolean; sponsorId: number }>(`/api/admin/sponsors/${sponsorId}`, { method: 'DELETE' }),
|
||||
getAdminArchivedWinners: () => requestJson<AdminArchivedWinnerItem[]>('/api/admin/archived-winners'),
|
||||
createAdminArchivedWinner: (payload: UpsertArchivedWinnerPayload) =>
|
||||
requestJson<{ saved: boolean; entry: AdminArchivedWinnerItem }>('/api/admin/archived-winners', jsonRequest('POST', payload)),
|
||||
updateAdminArchivedWinner: (archivedWinnerId: number, payload: UpsertArchivedWinnerPayload) =>
|
||||
requestJson<{ saved: boolean; entry: AdminArchivedWinnerItem }>(
|
||||
`/api/admin/archived-winners/${archivedWinnerId}`,
|
||||
jsonRequest('PUT', payload),
|
||||
),
|
||||
deleteAdminArchivedWinner: (archivedWinnerId: number) =>
|
||||
requestJson<{ deleted: boolean; archivedWinnerId: number }>(`/api/admin/archived-winners/${archivedWinnerId}`, { method: 'DELETE' }),
|
||||
getAdminSiteSettings: () => requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings'),
|
||||
uploadAdminHostImage: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings/host-image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
},
|
||||
getAdminOptionalFeatureSettings: () =>
|
||||
requestJson<AdminOptionalFeatureSettingsResponse>('/api/admin/optional-feature-settings'),
|
||||
updateAdminOptionalFeatureSettings: (payload: UpdateOptionalFeatureSettingsPayload) =>
|
||||
@@ -204,6 +225,8 @@ export const adminApi = {
|
||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, jsonRequest('POST', payload)),
|
||||
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
|
||||
requestJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, jsonRequest('PUT', payload)),
|
||||
getAdminCandidateDeletePreview: (candidateId: number) =>
|
||||
requestJson<AdminCandidateDeletePreview>(`/api/admin/candidates/${candidateId}/delete-preview`),
|
||||
deleteAdminCandidate: (candidateId: number) =>
|
||||
requestJson<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -39,6 +39,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/admin/AdminCandidatesView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'archive',
|
||||
name: 'admin-archive',
|
||||
component: () => import('../views/admin/AdminArchiveView.vue'),
|
||||
meta: { keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'clips',
|
||||
name: 'admin-clips',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type {
|
||||
ApproveNominationPayload,
|
||||
AddNominationLinkBlacklistEntryPayload,
|
||||
UpsertArchivedWinnerPayload,
|
||||
CreateSeasonPayload,
|
||||
CreateClipPayload,
|
||||
CreateNominationPayload,
|
||||
@@ -150,6 +151,7 @@ export const useAwardsStore = defineStore('awards', {
|
||||
this.adminOptionalFeatureSettings = createEmptyAdminOptionalFeatureSettings()
|
||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||
this.adminTrackingRules = createEmptyAdminTrackingRulesResponse()
|
||||
this.adminArchivedWinners = []
|
||||
this.adminRiskHistory = []
|
||||
this.adminRiskFlagsPage = createEmptyAdminRiskFlagsResponse()
|
||||
this.adminRiskHistoryPage = createEmptyAdminRiskFlagsResponse()
|
||||
@@ -215,6 +217,7 @@ export const useAwardsStore = defineStore('awards', {
|
||||
this.adminSponsors = []
|
||||
this.adminShowactApplications = []
|
||||
this.adminWorkflowRules = createEmptyAdminWorkflowRulesResponse()
|
||||
this.adminArchivedWinners = []
|
||||
}
|
||||
},
|
||||
async initializeAdminWorkspace() {
|
||||
@@ -316,6 +319,25 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
|
||||
return result
|
||||
},
|
||||
async loadAdminArchivedWinners() {
|
||||
this.adminArchivedWinners = await api.getAdminArchivedWinners()
|
||||
return this.adminArchivedWinners
|
||||
},
|
||||
async createAdminArchivedWinner(payload: UpsertArchivedWinnerPayload) {
|
||||
const result = await api.createAdminArchivedWinner(payload)
|
||||
await Promise.all([this.loadAdminArchivedWinners(), this.loadHomeData()])
|
||||
return result
|
||||
},
|
||||
async updateAdminArchivedWinner(archivedWinnerId: number, payload: UpsertArchivedWinnerPayload) {
|
||||
const result = await api.updateAdminArchivedWinner(archivedWinnerId, payload)
|
||||
await Promise.all([this.loadAdminArchivedWinners(), this.loadHomeData()])
|
||||
return result
|
||||
},
|
||||
async deleteAdminArchivedWinner(archivedWinnerId: number) {
|
||||
const result = await api.deleteAdminArchivedWinner(archivedWinnerId)
|
||||
await Promise.all([this.loadAdminArchivedWinners(), this.loadHomeData()])
|
||||
return result
|
||||
},
|
||||
async updateAdminSponsor(sponsorId: number, seasonId: number, payload: UpsertSponsorPayload) {
|
||||
const result = await api.updateAdminSponsor(sponsorId, payload)
|
||||
await Promise.all([this.loadAdminExtras(seasonId), this.loadPublicSponsors(this.overview.year)])
|
||||
@@ -561,6 +583,11 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.loadHomeData()
|
||||
return result
|
||||
},
|
||||
async uploadAdminHostImage(file: File) {
|
||||
this.adminSiteSettings = await api.uploadAdminHostImage(file)
|
||||
await this.loadHomeData()
|
||||
return this.adminSiteSettings
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiRequestError } from '../../lib/http'
|
||||
import type {
|
||||
AdminDashboardResponse,
|
||||
AdminArchivedWinnerItem,
|
||||
AdminOptionalFeatureSettingsResponse,
|
||||
AdminOperationalSettingsResponse,
|
||||
AdminRiskFlag,
|
||||
@@ -39,6 +40,8 @@ export function createEmptyOverview(): OverviewResponse {
|
||||
siteContent: {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
hostArtistName: '',
|
||||
hostImageUrl: '/assets/amaterasu2sei_2.png',
|
||||
newsletterUrl: '',
|
||||
shareXUrl: '',
|
||||
shareDiscordUrl: '',
|
||||
@@ -220,6 +223,8 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
|
||||
return {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
hostArtistName: '',
|
||||
hostImageUrl: '/assets/amaterasu2sei_2.png',
|
||||
newsletterUrl: '',
|
||||
shareXUrl: '',
|
||||
shareDiscordUrl: '',
|
||||
@@ -297,6 +302,7 @@ export function createAwardsState() {
|
||||
adminOptionalFeatureSettings: createEmptyAdminOptionalFeatureSettings(),
|
||||
adminWorkflowRules: createEmptyAdminWorkflowRulesResponse(),
|
||||
adminTrackingRules: createEmptyAdminTrackingRulesResponse(),
|
||||
adminArchivedWinners: [] as AdminArchivedWinnerItem[],
|
||||
adminSponsors: [] as AdminSponsorItem[],
|
||||
adminShowactApplications: [] as AdminShowactApplicationItem[],
|
||||
adminRiskHistory: [] as AdminRiskFlag[],
|
||||
@@ -331,6 +337,8 @@ export function normalizeSeasonDetail(detail: AdminSeasonDetailResponse): AdminS
|
||||
clipCompilationPlatform: candidate.clipCompilationPlatform ?? null,
|
||||
clipEmbedStatus: candidate.clipEmbedStatus ?? 'unchecked',
|
||||
streamerIdentityId: candidate.streamerIdentityId ?? null,
|
||||
avgViewers: candidate.avgViewers ?? null,
|
||||
votes: candidate.votes ?? 0,
|
||||
nominationTally: candidate.nominationTally ?? 0,
|
||||
})),
|
||||
pendingNominations: detail.pendingNominations ?? [],
|
||||
|
||||
@@ -264,6 +264,8 @@ export interface AdminCandidateItem {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
avgViewers: number | null
|
||||
votes: number
|
||||
nominationTally: number
|
||||
acceptanceStatus: string
|
||||
acceptanceNote: string | null
|
||||
@@ -273,6 +275,14 @@ export interface AdminCandidateItem {
|
||||
clipEmbedStatus: string
|
||||
}
|
||||
|
||||
export interface AdminCandidateDeletePreview {
|
||||
candidateId: number
|
||||
nominationCount: number
|
||||
clipCount: number
|
||||
voteCount: number
|
||||
resultCount: number
|
||||
}
|
||||
|
||||
export interface AdminAwardResultItem {
|
||||
id: number
|
||||
categoryId: number
|
||||
@@ -453,6 +463,8 @@ export interface AdminSeasonDetailResponse {
|
||||
export interface AdminSiteSettingsResponse {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
@@ -533,6 +545,17 @@ export interface AdminSponsorItem {
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
export interface AdminArchivedWinnerItem {
|
||||
id: number
|
||||
year: number
|
||||
category: string
|
||||
subcategory: string
|
||||
winnerName: string
|
||||
winnerUrl: string
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminShowactApplicationItem {
|
||||
id: number
|
||||
seasonId: number
|
||||
|
||||
@@ -244,6 +244,14 @@ export interface SetAwardResultPayload {
|
||||
candidateId: number
|
||||
}
|
||||
|
||||
export interface UpsertArchivedWinnerPayload {
|
||||
year: number
|
||||
category: string
|
||||
subcategory: string
|
||||
winnerName: string
|
||||
winnerUrl: string
|
||||
}
|
||||
|
||||
export interface ApproveNominationPayload {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
@@ -276,6 +284,7 @@ export interface AddNominationLinkBlacklistEntryPayload {
|
||||
export interface UpdateSiteSettingsPayload {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface PublicStreamBannerContent {
|
||||
export interface PublicSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
hostArtistName: string
|
||||
hostImageUrl: string
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { Archive, Link as LinkIcon, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import { useAdminArchiveManager } from '../../components/admin/useAdminArchiveManager'
|
||||
|
||||
const {
|
||||
loading,
|
||||
adminError,
|
||||
adminMessage,
|
||||
groupedDrafts,
|
||||
stats,
|
||||
loadArchivedWinners,
|
||||
addDraft,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
} = useAdminArchiveManager()
|
||||
|
||||
watchAdminToast(adminMessage, adminError)
|
||||
|
||||
onMounted(() => {
|
||||
void loadArchivedWinners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Archiv"
|
||||
description="Historische Gewinner für die Landingpage unabhängig vom aktiven Award-Jahr pflegen. Änderungen landen direkt in der öffentlichen Archivquelle."
|
||||
:icon="Archive"
|
||||
/>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-3">
|
||||
<Card v-for="card in stats" :key="card.label" class="p-4">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">{{ card.label }}</p>
|
||||
<strong class="mt-2 block text-2xl text-slate-950">{{ card.value }}</strong>
|
||||
<p class="mt-1 text-sm leading-5 text-slate-500">{{ card.note }}</p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card class="p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-950">Archiv-Gewinner verwalten</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Pro Zeile kannst du Jahr, Gewinner, Link, Kategorie und Unterkategorie anlegen, anpassen oder löschen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button class="gap-2" @click="addDraft">
|
||||
<Plus class="h-4 w-4" />
|
||||
Neue Zeile
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div v-if="loading" class="rounded-[26px] border border-dashed border-violet-200 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
|
||||
Archiv wird geladen ...
|
||||
</div>
|
||||
|
||||
<div v-else-if="groupedDrafts.length === 0" class="rounded-[26px] border border-dashed border-violet-200 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
|
||||
Noch keine Archiv-Gewinner vorhanden.
|
||||
</div>
|
||||
|
||||
<section v-else class="space-y-5">
|
||||
<div
|
||||
v-for="group in groupedDrafts"
|
||||
:key="group.key"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-600">Archiv</p>
|
||||
<h2 class="text-2xl font-bold text-slate-950">{{ group.label }}</h2>
|
||||
</div>
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
|
||||
{{ group.items.length }} Einträge
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-2">
|
||||
<Card
|
||||
v-for="draft in group.items"
|
||||
:key="draft.localKey"
|
||||
class="p-5"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-4 sm:grid-cols-[120px_minmax(0,1fr)]">
|
||||
<label class="grid min-w-0 gap-1.5">
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Jahr</span>
|
||||
<input
|
||||
v-model="draft.year"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="4"
|
||||
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="2025"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1.5">
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Gewinner</span>
|
||||
<input
|
||||
v-model="draft.winnerName"
|
||||
type="text"
|
||||
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Name des Gewinners"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="grid gap-1.5">
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Link</span>
|
||||
<div class="relative">
|
||||
<LinkIcon class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="draft.winnerUrl"
|
||||
type="url"
|
||||
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="https://twitch.tv/..."
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="grid gap-1.5">
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Kategorie</span>
|
||||
<input
|
||||
v-model="draft.category"
|
||||
type="text"
|
||||
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="z. B. Rising Stars"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1.5">
|
||||
<span class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">Unterkategorie</span>
|
||||
<input
|
||||
v-model="draft.subcategory"
|
||||
type="text"
|
||||
class="h-11 rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="z. B. Aufbau"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-violet-100 pt-4">
|
||||
<p class="text-xs text-slate-400">
|
||||
{{ draft.updatedAt ? `Zuletzt aktualisiert: ${new Date(draft.updatedAt).toLocaleString('de-DE')}` : draft.isNew ? 'Neue Archivzeile' : 'Noch nicht aktualisiert' }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="gap-2"
|
||||
:disabled="draft.isDeleting || draft.isSaving"
|
||||
@click="deleteDraft(draft.localKey)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
{{ draft.isDeleting ? 'Löscht ...' : 'Löschen' }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
class="gap-2"
|
||||
:disabled="draft.isDeleting || draft.isSaving"
|
||||
@click="saveDraft(draft.localKey)"
|
||||
>
|
||||
<Save class="h-4 w-4" />
|
||||
{{ draft.isSaving ? 'Speichert ...' : 'Speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,7 +25,6 @@ const {
|
||||
categoryFilterOptions,
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
candidateIdentitySummaries,
|
||||
candidateWorkflowNotices,
|
||||
duplicateCandidateCount,
|
||||
acceptedCandidateCount,
|
||||
@@ -48,9 +47,13 @@ const {
|
||||
clipEmbedStatusOptions,
|
||||
readinessFilterOptions,
|
||||
candidateToDelete,
|
||||
deletePreviewLoading,
|
||||
candidateDeletePreview,
|
||||
clearFilters,
|
||||
openCreate,
|
||||
openEdit,
|
||||
openDelete,
|
||||
closeDeleteModal,
|
||||
handlePlatformSelection,
|
||||
saveModal,
|
||||
confirmDelete,
|
||||
@@ -149,11 +152,10 @@ 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"
|
||||
@delete="candidateToDelete = $event"
|
||||
@delete="openDelete"
|
||||
@update:page="page = $event"
|
||||
@open-create="openCreate"
|
||||
/>
|
||||
@@ -189,8 +191,10 @@ watchAdminToast(adminMessage, adminError)
|
||||
|
||||
<AdminCandidateDeleteModal
|
||||
:candidate="candidateToDelete"
|
||||
:preview="candidateDeletePreview"
|
||||
:loading="deletePreviewLoading"
|
||||
:deleting="deleting"
|
||||
@close="candidateToDelete = null"
|
||||
@close="closeDeleteModal"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ const {
|
||||
saveError,
|
||||
privacyPreviewOpen,
|
||||
iconUploadError,
|
||||
hostImageUploadError,
|
||||
hostImageUploading,
|
||||
privacyPreviewHtml,
|
||||
privacyUpdatedLabel,
|
||||
addSocialLink,
|
||||
@@ -47,6 +49,7 @@ const {
|
||||
socialSimpleIconColor,
|
||||
handleSocialIconUpload,
|
||||
clearSocialIcon,
|
||||
handleHostImageUpload,
|
||||
saveSiteSettings,
|
||||
adminSiteSettings,
|
||||
} = useAdminContentManager()
|
||||
@@ -164,7 +167,14 @@ function closeContentEditor() {
|
||||
:icon="FileText"
|
||||
/>
|
||||
|
||||
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
|
||||
<AdminContentBasicsSection
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:host-image-uploading="hostImageUploading"
|
||||
:host-image-upload-error="hostImageUploadError"
|
||||
:handle-host-image-upload="handleHostImageUpload"
|
||||
:save-site-settings="saveSiteSettings"
|
||||
/>
|
||||
|
||||
<section aria-labelledby="content-editor-title" class="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -66,6 +66,7 @@ const fullNavGroups = computed(() => [
|
||||
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
|
||||
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
|
||||
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
|
||||
{ label: 'Archiv', to: '/admin/archive', description: 'Historische Gewinner pflegen', icon: Trophy, permission: 'winners', badge: () => store.adminArchivedWinners.length > 0 ? `${store.adminArchivedWinners.length}` : null },
|
||||
...(clipAdminMenuVisible.value ? [{
|
||||
label: 'Clips',
|
||||
to: '/admin/clips',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, KeyRound, LockKeyhole, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
|
||||
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminTeamRolePermissionsModal from '../../components/admin/AdminTeamRolePermissionsModal.vue'
|
||||
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
|
||||
import { useBodyScrollLock } from '../../composables/useBodyScrollLock'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
@@ -22,7 +21,6 @@ const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authSt
|
||||
const passwordModalOpen = ref(false)
|
||||
const createMemberModalOpen = ref(false)
|
||||
const editMemberModalOpen = ref(false)
|
||||
const rolePermissionsModalOpen = ref(false)
|
||||
const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
const ownPasswordForm = reactive({
|
||||
@@ -132,6 +130,10 @@ function canDeleteMember(member: AdminTeamMember) {
|
||||
return true
|
||||
}
|
||||
|
||||
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
|
||||
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
|
||||
}
|
||||
|
||||
function openCreateMemberModal() {
|
||||
startCreateMember()
|
||||
createMemberModalOpen.value = true
|
||||
@@ -281,16 +283,6 @@ async function changeOwnPassword() {
|
||||
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="border border-violet-200 bg-violet-50 text-violet-700 shadow-sm shadow-violet-200/40 hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-100 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/60"
|
||||
@click="rolePermissionsModalOpen = true"
|
||||
>
|
||||
<ShieldCheck class="mr-2 h-4 w-4" />
|
||||
Rollenrechte
|
||||
</Button>
|
||||
<Button size="sm" type="button" @click="openCreateMemberModal">
|
||||
<UserPlus class="mr-2 h-4 w-4" />
|
||||
Neuer Login
|
||||
@@ -377,18 +369,84 @@ async function changeOwnPassword() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<AdminTeamRolePermissionsModal
|
||||
:open="rolePermissionsModalOpen"
|
||||
:saving="saving"
|
||||
:has-role-changes="hasRoleChanges"
|
||||
:roles="roles"
|
||||
:permissions="permissions"
|
||||
:permission-groups="permissionGroups"
|
||||
:role-has-permission="roleHasPermission"
|
||||
:set-role-permission="setRolePermission"
|
||||
:save-role-permissions="saveRolePermissions"
|
||||
@close="rolePermissionsModalOpen = false"
|
||||
/>
|
||||
<Card class="p-4 md:p-5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
|
||||
<p class="mt-1 text-xs font-semibold text-slate-500">
|
||||
Alle Rollen bleiben direkt vergleichbar, ohne zwischen Einzelansichten zu wechseln.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
{{ saving ? 'Speichert...' : 'Speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
|
||||
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
|
||||
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
|
||||
<tr>
|
||||
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
|
||||
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span>{{ role.label }}</span>
|
||||
<span
|
||||
v-if="role.key === 'owner' || role.key === 'creator'"
|
||||
class="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.12em] text-slate-500"
|
||||
>
|
||||
fixiert
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-violet-100 bg-white/70">
|
||||
<tr v-for="permission in permissions" :key="permission.key">
|
||||
<td class="sticky left-0 z-10 max-w-[280px] bg-white px-3 py-3 align-top">
|
||||
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
|
||||
<span class="mt-1 block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
|
||||
<span class="mt-2 inline-flex rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[10px] font-bold text-slate-500">
|
||||
{{ permission.menuPath }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
|
||||
<label class="inline-flex cursor-pointer items-center justify-center" :class="role.key === 'owner' || role.key === 'creator' ? 'cursor-not-allowed' : ''">
|
||||
<input
|
||||
class="sr-only"
|
||||
type="checkbox"
|
||||
:checked="roleHasPermission(role.key, permission.key)"
|
||||
:disabled="role.key === 'owner' || role.key === 'creator'"
|
||||
:aria-label="`${role.label}: ${permission.label}`"
|
||||
@change="handlePermissionChange(role.key, permission.key, $event)"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-9 min-w-20 items-center justify-center gap-2 rounded-xl border px-3 text-sm font-bold transition"
|
||||
:class="roleHasPermission(role.key, permission.key)
|
||||
? role.key === 'owner' || role.key === 'creator'
|
||||
? 'border-slate-200 bg-slate-100 text-slate-500'
|
||||
: 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20'
|
||||
: role.key === 'owner' || role.key === 'creator'
|
||||
? 'border-slate-200 bg-slate-50 text-slate-300'
|
||||
: 'border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
|
||||
>
|
||||
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-4 w-4" :stroke-width="3" />
|
||||
<Minus v-else class="h-4 w-4" :stroke-width="3" />
|
||||
{{ roleHasPermission(role.key, permission.key) ? 'An' : 'Aus' }}
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
|
||||
@@ -77,7 +77,7 @@ function windowSupportText(rule: AdminTrackingMetricRule) {
|
||||
.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'}.`
|
||||
return `Dieses Zeitfenster ist aktuell nicht automatisch verfügbar. Auto geht nur für: ${labels || 'keine Zeitfenster'}.`
|
||||
}
|
||||
|
||||
function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'optional') {
|
||||
@@ -93,9 +93,9 @@ function effectSummary(rule: AdminTrackingMetricRule, section: 'important' | 'op
|
||||
}
|
||||
|
||||
if (rule.sourceSupport === 'auto') {
|
||||
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstuetzt wird.')
|
||||
parts.push('Werte kommen direkt aus TwitchTracker, sofern das Zeitfenster unterstützt wird.')
|
||||
} else if (rule.sourceSupport === 'manual') {
|
||||
parts.push('Diese Metrik ist fuer manuelle Pflege gedacht.')
|
||||
parts.push('Diese Metrik ist für manuelle Pflege gedacht.')
|
||||
} else {
|
||||
parts.push('Diese Metrik dient nur als Review-Kontext.')
|
||||
}
|
||||
@@ -174,7 +174,7 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
<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.
|
||||
Diese Base URL wird für automatische TwitchTracker-Lookups verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || sourceSaving || !hasUnsavedTrackingSourceChanges || !canManageTrackingRules" @click="saveTrackingSource">
|
||||
@@ -210,9 +210,9 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
<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>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Admin-Notizen für 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.
|
||||
Diese Notizen erscheinen im Nominierungsreview als Hilfe für den manuellen Check.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="trackingLoading || notesSaving || !hasUnsavedTrackingNotesChanges || !canManageTrackingRules" @click="saveTrackingNotes">
|
||||
@@ -247,7 +247,7 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
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"
|
||||
placeholder="- SullyGnome Channel Summary - manueller Check für Category Fit - Sonderregeln für kleine Kanäle"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
@@ -296,7 +296,7 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
{{ 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
|
||||
Pflicht für 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
|
||||
@@ -328,7 +328,7 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
</label>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.requiredForAutoClassification"
|
||||
:label="`${rule.label} fuer Auto-Check erzwingen`"
|
||||
:label="`${rule.label} für Auto-Check erzwingen`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Pflicht"
|
||||
inactive-label="Nicht Pflicht"
|
||||
@@ -373,7 +373,7 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-500">Nur aktive optionale Metriken können im Nominierungsreview erscheinen. Deaktivierte Metriken werden dort komplett ausgeblendet.</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 p-5">
|
||||
@@ -425,8 +425,8 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
|
||||
<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>
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Review-Kontext für 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 für Admins.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -555,6 +555,7 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
<Card class="overflow-visible">
|
||||
<section class="border-b border-violet-100 p-5">
|
||||
<h2 class="text-xl font-bold text-slate-900">Flags</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Tracking-Flags markieren Review-Faelle und Hinweise, blockieren aber keine Annahme oder Verwerfung mehr.</p>
|
||||
</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">
|
||||
@@ -599,23 +600,20 @@ function activateOptionalMetric(ruleKey: string) {
|
||||
:model-value="rule.requiresManualReview"
|
||||
:label="`${rule.label} braucht Review`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Review noetig"
|
||||
active-label="Review nötig"
|
||||
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 })"
|
||||
/>
|
||||
<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">Approve-Verhalten</p>
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900">Warnt nur</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">Tracking-Flags sind reine Zusatzinfos für den Review-Kontext und sperren die Entscheidung nicht.</p>
|
||||
</div>
|
||||
<AdminSettingsToggle
|
||||
:model-value="rule.adminNoteRequiredOnOverride"
|
||||
:label="`${rule.label} braucht Override-Notiz`"
|
||||
:disabled="!canManageTrackingRules"
|
||||
active-label="Notiz noetig"
|
||||
active-label="Notiz nötig"
|
||||
inactive-label="Optional"
|
||||
@update:model-value="updateTrackingFlag(rule.key, { adminNoteRequiredOnOverride: $event })"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
seasonCandidates,
|
||||
votingGroups,
|
||||
selectedGroupName,
|
||||
focusedCategoryId,
|
||||
@@ -117,6 +118,7 @@ function openLeaderboard(category: AdminVotingWorkspaceRow) {
|
||||
|
||||
<AdminVotingLeaderboardModal
|
||||
:category="leaderboardCategory"
|
||||
:candidates="seasonCandidates"
|
||||
@close="leaderboardCategory = null"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ watchAdminToast(adminMessage, adminError)
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Gewinner"
|
||||
description="Finale Gewinner pro Unterkategorie setzen, Guards pruefen und Landingpage-Freigabe vorbereiten."
|
||||
description="Finale Gewinner pro Unterkategorie setzen, Guards prüfen und Landingpage-Freigabe vorbereiten."
|
||||
:icon="Trophy"
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user