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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user