Update release notes and deploy workspace
This commit is contained in:
@@ -1,31 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
|
||||
import { AlertTriangle, Clock3, EyeOff, Filter, Trophy, UploadCloud } from '@lucide/vue'
|
||||
|
||||
import AdminWinnersTreeWorkspace from '../../components/admin/AdminWinnersTreeWorkspace.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
|
||||
import { watchAdminToast } from '../../composables/useAdminToast'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import NativeSelect from '../../components/ui/NativeSelect.vue'
|
||||
|
||||
const {
|
||||
adminError,
|
||||
adminMessage,
|
||||
completionPct,
|
||||
clearFilters,
|
||||
deletingResultId,
|
||||
focusedCategoryId,
|
||||
publicationState,
|
||||
publishingWinners,
|
||||
query,
|
||||
savingResultForCategory,
|
||||
selectedGroupName,
|
||||
selectGroup,
|
||||
focusCategory,
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
visibleResultRows,
|
||||
winnerGroups,
|
||||
winnerSelections,
|
||||
unpublishingWinners,
|
||||
clearWinner,
|
||||
publishWinners,
|
||||
saveWinner,
|
||||
workflowRuleSummary,
|
||||
selectedWinnerIdentitySummary,
|
||||
selectedWinnerClipPreview,
|
||||
unpublishWinners,
|
||||
winnerRuleNoticeFor,
|
||||
} = useAdminWinnersManager()
|
||||
|
||||
@@ -36,12 +43,76 @@ watchAdminToast(adminMessage, adminError)
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Gewinner"
|
||||
description="Finale Gewinner je Kategorie setzen, aktualisieren und fuer Archiv sowie Public-Ansicht freigeben."
|
||||
description="Finale Gewinner pro Unterkategorie setzen, Guards pruefen und Landingpage-Freigabe vorbereiten."
|
||||
:icon="Trophy"
|
||||
/>
|
||||
|
||||
<AdminSeasonToolbar />
|
||||
|
||||
<Card class="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-start">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold uppercase tracking-[0.12em]" :class="publicationState.statusTone">
|
||||
<UploadCloud v-if="!publicationState.isPublished" class="h-4 w-4" />
|
||||
<Trophy v-else class="h-4 w-4" />
|
||||
{{ publicationState.statusLabel }}
|
||||
</span>
|
||||
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1.5 text-xs font-semibold text-violet-700">
|
||||
Phase: {{ publicationState.phaseLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-xl font-bold text-slate-900">Landingpage-Freigabe</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Gewinner bleiben intern, bis dieses Award-Jahr veröffentlicht wird. Nach der Freigabe erscheinen sie auf der Landingpage; der bisherige Landingpage-Jahrgang wandert ins Archiv.
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-semibold text-slate-600">
|
||||
{{ publicationState.publishedAtLabel }}
|
||||
<span v-if="publicationState.isPublished" class="text-slate-400">· {{ publicationState.publishedByLabel }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="publicationState.issues.length > 0" class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="issue in publicationState.issues"
|
||||
:key="issue.key"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="issue.isWarning ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-violet-100 bg-violet-50 text-violet-800'"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle v-if="issue.isWarning" class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<Clock3 v-else class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-bold">{{ issue.label }}</p>
|
||||
<p class="mt-1 text-xs leading-5 opacity-80">{{ issue.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 xl:justify-end">
|
||||
<Button
|
||||
:disabled="publicationState.isPublished || !publicationState.canPublish || publishingWinners"
|
||||
class="gap-2"
|
||||
@click="publishWinners"
|
||||
>
|
||||
<UploadCloud class="h-4 w-4" />
|
||||
{{ publishingWinners ? 'Veröffentlicht ...' : publicationState.isPublished ? 'Veröffentlicht' : 'Veröffentlichen' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="publicationState.isPublished"
|
||||
variant="secondary"
|
||||
class="gap-2"
|
||||
:disabled="unpublishingWinners"
|
||||
@click="unpublishWinners"
|
||||
>
|
||||
<EyeOff class="h-4 w-4" />
|
||||
{{ unpublishingWinners ? 'Nimmt zurück ...' : 'Zurücknehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card v-for="card in summaryCards" :key="card.label" class="p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
@@ -54,220 +125,66 @@ watchAdminToast(adminMessage, adminError)
|
||||
<component :is="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="typeof card.progress === 'number'" class="mt-4">
|
||||
<div class="h-3 overflow-hidden rounded-full bg-violet-100">
|
||||
<div class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all" :style="{ width: `${card.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-center">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Winner Lock-In</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Gewinner freigeben</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Pro Kategorie kann genau ein Gewinner gesetzt werden. Offene Reviews werden sichtbar markiert, blockieren die technische Freigabe aber nicht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
<span>Fortschritt</span>
|
||||
<span>{{ completionPct }}%</span>
|
||||
</div>
|
||||
<div class="mt-2 h-3 overflow-hidden rounded-full bg-violet-100">
|
||||
<div class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all" :style="{ width: `${completionPct}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<label class="relative block">
|
||||
<Filter class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Kategorie, Gewinner oder Kandidat suchen"
|
||||
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-10 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in statusFilters"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-xl border px-3 text-sm font-semibold transition"
|
||||
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="rounded-full bg-white/25 px-2 py-0.5 text-xs">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<span
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
:class="workflowRuleSummary.maxWinnerPlacements?.enabled ? 'border-violet-200 bg-violet-50 text-violet-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
|
||||
>
|
||||
{{ workflowRuleSummary.maxWinnerPlacements?.enabled
|
||||
? `Gewinnerlimit aktiv: ${workflowRuleSummary.maxWinnerPlacements.limit}x pro Person`
|
||||
: 'Gewinnerlimit aus' }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
|
||||
:class="workflowRuleSummary.winnerRequiresClip?.enabled ? 'border-amber-200 bg-amber-50 text-amber-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
|
||||
>
|
||||
{{ workflowRuleSummary.winnerRequiresClip?.enabled
|
||||
? `Clip-Pflicht ${workflowRuleSummary.winnerRequiresClip.mode === 'warn' ? 'als Warnung' : 'als Blocker'}`
|
||||
: 'Clip-Pflicht aus' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
|
||||
<article
|
||||
v-for="row in visibleResultRows"
|
||||
:key="row.category.id"
|
||||
class="grid gap-4 px-5 py-4 2xl:grid-cols-[minmax(0,1fr)_420px_auto] 2xl:items-center"
|
||||
<section class="grid gap-4 xl:grid-cols-[minmax(260px,0.9fr)_minmax(0,1.1fr)_auto] xl:items-center">
|
||||
<label class="relative block">
|
||||
<Filter class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Hauptkategorie, Unterkategorie oder Gewinner suchen"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="truncate font-semibold text-slate-900">{{ row.category.name }}</p>
|
||||
<span v-if="row.existing" class="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
<CheckCircle2 class="h-3.5 w-3.5" />
|
||||
gesetzt
|
||||
</span>
|
||||
<span v-if="row.hasPendingReviews" class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
{{ row.openReviews }} Reviews offen
|
||||
</span>
|
||||
<span v-if="row.isEmpty" class="rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700">
|
||||
keine Kandidaten
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ row.category.groupName }} · {{ row.candidates.length }} Kandidaten · {{ row.approvedClips }} Clips freigegeben
|
||||
</p>
|
||||
<p v-if="row.existing" class="mt-2 inline-flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">
|
||||
<Award class="h-4 w-4" />
|
||||
Aktuell: {{ row.existing.candidateDisplayName }} · {{ row.existing.candidatePlatform }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<NativeSelect
|
||||
v-model="winnerSelections[row.category.id]"
|
||||
:disabled="row.isEmpty"
|
||||
:options="[
|
||||
{ label: 'Bitte Gewinner waehlen', value: '' },
|
||||
...row.candidates.map((candidate) => ({
|
||||
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform} · ${candidate.clipCompilationUrl ? 'Clip gepflegt' : 'kein Clip'}`,
|
||||
value: `${candidate.id}`,
|
||||
})),
|
||||
]"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="winnerRuleNoticeFor(row.category.id)"
|
||||
class="rounded-2xl border px-3 py-2 text-sm font-semibold"
|
||||
:class="winnerRuleNoticeFor(row.category.id)?.mode === 'block'
|
||||
? 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
: 'border-amber-100 bg-amber-50 text-amber-700'"
|
||||
>
|
||||
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
|
||||
{{ winnerRuleNoticeFor(row.category.id)?.message }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="selectedWinnerIdentitySummary(row.category.id)"
|
||||
class="grid gap-2 sm:grid-cols-4"
|
||||
>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidaturen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.appearances }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Gewinner</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.winnerPlacements }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Viewer-Nominierungen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.nominationTally }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Clip</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.hasClip ? 'vorhanden' : 'fehlt' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedWinnerClipPreview(row.category.id)"
|
||||
class="rounded-[22px] border border-violet-100 bg-violet-50/40 p-3"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Winner Preview</p>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-semibold text-violet-700">
|
||||
{{ selectedWinnerClipPreview(row.category.id)?.clipPlatform }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
v-if="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl"
|
||||
:src="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl ?? undefined"
|
||||
:title="selectedWinnerClipPreview(row.category.id)?.clipEmbedTitle"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
class="w-full rounded-2xl border-0 bg-slate-950"
|
||||
style="aspect-ratio:16/9;"
|
||||
/>
|
||||
|
||||
<a
|
||||
v-else-if="selectedWinnerClipPreview(row.category.id)?.clipUrl"
|
||||
:href="selectedWinnerClipPreview(row.category.id)?.clipUrl ?? undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-violet-100 bg-white px-4 text-center text-sm font-semibold text-violet-700 hover:bg-violet-100/60"
|
||||
>
|
||||
{{ selectedWinnerClipPreview(row.category.id)?.clipTitle }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 text-center text-sm font-semibold text-amber-700"
|
||||
>
|
||||
Noch kein Clip-Link gepflegt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
:disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id] || winnerRuleNoticeFor(row.category.id)?.mode === 'block'"
|
||||
@click="saveWinner(row.category.id)"
|
||||
>
|
||||
{{ savingResultForCategory === row.category.id ? 'Speichert ...' : row.existing ? 'Aktualisieren' : 'Setzen' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="row.existing"
|
||||
variant="secondary"
|
||||
class="gap-1.5"
|
||||
:disabled="deletingResultId === row.existing.id"
|
||||
@click="clearWinner(row.existing.id)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
{{ deletingResultId === row.existing.id ? 'Entfernt ...' : 'Entfernen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="option in statusFilters"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border bg-white/90 px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="statusFilter === option.value ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 text-slate-600 hover:bg-violet-50'"
|
||||
@click="statusFilter = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
<span class="rounded-full bg-white/70 px-2 py-0.5 text-[11px]">{{ option.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-8 text-center">
|
||||
<Trophy class="mx-auto h-8 w-8 text-violet-300" />
|
||||
<p class="mt-3 font-semibold text-slate-900">Keine Kategorien fuer diese Filter.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Passe Suche oder Statusfilter an.</p>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
v-if="query || statusFilter !== 'all'"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Filter reset
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<AdminWinnersTreeWorkspace
|
||||
:groups="winnerGroups"
|
||||
:selected-group-name="selectedGroupName"
|
||||
:focused-category-id="focusedCategoryId"
|
||||
:winner-selections="winnerSelections"
|
||||
:saving-result-for-category="savingResultForCategory"
|
||||
:deleting-result-id="deletingResultId"
|
||||
:selected-winner-identity-summary="selectedWinnerIdentitySummary"
|
||||
:winner-rule-notice-for="winnerRuleNoticeFor"
|
||||
@select-group="selectGroup"
|
||||
@select-category="focusCategory"
|
||||
@update:winner-selection="(categoryId: number, value: string) => { winnerSelections[categoryId] = value }"
|
||||
@save="saveWinner"
|
||||
@clear="clearWinner"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user