Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, CheckCircle2, Loader2, Trash2 } from '@lucide/vue'
|
||||
import { Ban, CheckCircle2, Link2, Loader2, ShieldAlert, Trash2, UserRoundCheck, WandSparkles } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
import type { AdminCandidateItem, AdminNominationReviewGroup, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nomination: AdminNominationReviewItem | null
|
||||
const props = defineProps<{
|
||||
nomination: AdminNominationReviewGroup | null
|
||||
reviewSaving: number | null
|
||||
reviewForm: {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
categoryId: number | null
|
||||
reviewNote: string
|
||||
} | null
|
||||
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||
categoryOptions: Array<{ id: number; label: string }>
|
||||
selectedCandidateCollision: AdminCandidateItem | null
|
||||
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||
signalSummary?: {
|
||||
@@ -22,152 +24,291 @@ defineProps<{
|
||||
uniqueSubmitters: number
|
||||
platforms: string[]
|
||||
} | null
|
||||
trackingReviewNotes?: string
|
||||
selectedNominationHasBlockingFlag?: boolean
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
blacklistSaving: boolean
|
||||
selectedPlatformValue: (platform: string) => string
|
||||
trackerStatusMeta: (status: string) => { label: string; toneClass: string; hint: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'platform-change': [value: string]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
reviewFlagged: [nominationId: number]
|
||||
overrideFlagged: [nominationId: number]
|
||||
'blacklist-link': [streamUrl: string]
|
||||
}>()
|
||||
|
||||
function avgViewerWindowLabel(nomination: AdminNominationReviewGroup) {
|
||||
return nomination.trackingMetrics.find((metric) => metric.key === 'avg_viewers')?.windowLabel?.trim() ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
||||
ID {{ nomination.id }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-4 shadow-[0_14px_36px_rgba(168,145,214,0.08)] sm:p-5">
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-[linear-gradient(135deg,#fcfaff_0%,#ffffff_100%)] p-4 sm:p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-violet-100 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-violet-800">
|
||||
{{ nomination.categoryGroupName }}
|
||||
</span>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-500">
|
||||
Gruppe #{{ nomination.id }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-3 break-words text-xl font-bold text-slate-900 sm:text-2xl">{{ nomination.displayName || 'Name im Review festlegen' }}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
{{ nomination.uniqueSubmitterCount }} eindeutige Nominierende · {{ nomination.nominationTally }} Nominierungen · zuletzt {{ new Date(nomination.lastSubmittedAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||
>
|
||||
<span class="truncate">{{ streamUrl }}</span>
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-full border-rose-200 bg-white px-3 text-rose-700 hover:bg-rose-50 hover:text-rose-800"
|
||||
:disabled="blacklistSaving"
|
||||
@click="emit('blacklist-link', streamUrl)"
|
||||
>
|
||||
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Ban v-else class="h-3.5 w-3.5" />
|
||||
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-2xl border px-4 py-3 text-sm"
|
||||
:class="props.trackerStatusMeta(nomination.trackerStatus).toneClass"
|
||||
>
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.18em]">Tracker</p>
|
||||
<p class="mt-1 font-semibold">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Einreichungen</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige User</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
|
||||
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tier-Vorschlag</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ nomination.suggestedCategoryName || 'manuell wählen' }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Avg Viewer <span v-if="avgViewerWindowLabel(nomination)">· {{ avgViewerWindowLabel(nomination) }}</span></p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ nomination.avgViewers ?? 'offen' }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ signalSummary?.platforms.join(', ') || nomination.resolvedPlatform || 'keine' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input
|
||||
v-model="reviewForm.displayName"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Anzeigename"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input
|
||||
v-model="reviewForm.channelSlug"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="channel"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<NativeSelect
|
||||
:model-value="selectedPlatformValue(reviewForm.platform)"
|
||||
:options="[
|
||||
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
|
||||
{ label: 'Eigene Plattform', value: 'custom' },
|
||||
]"
|
||||
@update:model-value="emit('platform-change', String($event))"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||
<input
|
||||
v-model="reviewForm.platform"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="z. B. Cake, Booth, neue Plattform"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
|
||||
</p>
|
||||
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
|
||||
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
|
||||
</p>
|
||||
<label class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
|
||||
<textarea
|
||||
v-model="reviewForm.reviewNote"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Warum wurde übernommen oder verworfen?"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-violet-50/35 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Link2 class="h-4 w-4 text-sky-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Quelle & Signale</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" @click="emit('approve', nomination.id)">
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||
</Button>
|
||||
<div v-if="streamUrl" class="mt-3 rounded-2xl border border-sky-100 bg-white p-3">
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="block truncate text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||
>
|
||||
{{ streamUrl }}
|
||||
</a>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-full border-rose-200 bg-rose-50 px-3 text-rose-700 hover:bg-rose-100 hover:text-rose-800"
|
||||
:disabled="blacklistSaving"
|
||||
@click="emit('blacklist-link', streamUrl)"
|
||||
>
|
||||
<Loader2 v-if="blacklistSaving" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Ban v-else class="h-3.5 w-3.5" />
|
||||
{{ blacklistSaving ? 'Blockiert ...' : 'Zur Blacklist' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="signalSummary" class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Nominierungen</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige Nominierende</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</p>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ props.trackerStatusMeta(nomination.trackerStatus).label }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/75 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-800">Nominierungen dieser Gruppe</p>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="related in selectedRelatedPendingNominations"
|
||||
:key="related.id"
|
||||
class="rounded-2xl border border-amber-100 bg-white px-3 py-2 text-sm text-slate-700"
|
||||
>
|
||||
<p class="font-semibold">#{{ related.id }} · {{ related.submittedByTwitchId }}</p>
|
||||
<p class="mt-1 break-words text-slate-500">{{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="rounded-[24px] border p-4"
|
||||
:class="nomination.trackingReviewStatus === 'overridden' ? 'border-emerald-100 bg-emerald-50/70' : nomination.requiresManualReview ? 'border-amber-100 bg-amber-50/70' : 'border-violet-100 bg-white'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldAlert class="h-4 w-4" :class="nomination.requiresManualReview ? 'text-amber-700' : 'text-violet-600'" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em]" :class="nomination.requiresManualReview ? 'text-amber-800' : 'text-violet-500'">Tracking Review</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900">{{ props.trackerStatusMeta(nomination.trackerStatus).hint }}</p>
|
||||
|
||||
<div v-if="nomination.trackingFlags.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="flag in nomination.trackingFlags"
|
||||
:key="flag.key"
|
||||
class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-semibold text-amber-800"
|
||||
>
|
||||
{{ flag.label }}<span v-if="flag.blocksApproval"> · blockiert</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="nomination.trackingMetrics.length" class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div v-for="metric in nomination.trackingMetrics" :key="metric.key" class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">{{ metric.label }}</p>
|
||||
<span
|
||||
class="rounded-full border px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="metric.sourceSupport === 'auto'
|
||||
? (metric.present ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700')
|
||||
: 'border-slate-200 bg-slate-50 text-slate-700'"
|
||||
>
|
||||
{{ metric.sourceSupport === 'auto' ? (metric.present ? 'vorhanden' : 'fehlt') : (metric.sourceSupport === 'manual' ? 'manuell' : 'kontext') }}
|
||||
</span>
|
||||
</div>
|
||||
<strong class="mt-1 block text-sm text-violet-900">{{ metric.value }}</strong>
|
||||
<p class="mt-1 text-xs text-slate-500">{{ metric.required ? 'Pflichtmetrik' : 'Optionale Metrik' }} · {{ metric.windowLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="trackingReviewNotes" class="mt-4 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">Manual Review Notes</p>
|
||||
<pre class="mt-2 whitespace-pre-wrap text-sm leading-6 text-sky-900">{{ trackingReviewNotes }}</pre>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<section class="rounded-[24px] border border-violet-100 bg-violet-50/45 p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserRoundCheck class="h-4 w-4 text-violet-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Kandidaten-Übernahme</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input
|
||||
v-model="reviewForm.displayName"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Anzeigename"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input
|
||||
v-model="reviewForm.channelSlug"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="channel"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Tier</span>
|
||||
<NativeSelect
|
||||
:model-value="reviewForm.categoryId ?? ''"
|
||||
:options="categoryOptions.map((option) => ({ label: option.label, value: option.id }))"
|
||||
@update:model-value="reviewForm.categoryId = Number($event) || null"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<NativeSelect
|
||||
:model-value="selectedPlatformValue(reviewForm.platform)"
|
||||
:options="[
|
||||
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
|
||||
{ label: 'Eigene Plattform', value: 'custom' },
|
||||
]"
|
||||
@update:model-value="emit('platform-change', String($event))"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||
<input
|
||||
v-model="reviewForm.platform"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="z. B. Cake, Booth, neue Plattform"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="!nomination.suggestedCategoryId"
|
||||
class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800"
|
||||
>
|
||||
Kein automatischer Tier-Vorschlag vorhanden. Bitte manuell anhand Viewer-Range und Kanal einordnen.
|
||||
</p>
|
||||
|
||||
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in diesem Tier bereits vorhanden.
|
||||
</p>
|
||||
|
||||
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
<span v-if="selectedNominationHasBlockingFlag && nomination.trackingReviewStatus !== 'overridden'">
|
||||
Mindestens ein Tracking-Flag blockiert die Freigabe. Bitte zuerst einen Override setzen.
|
||||
</span>
|
||||
<span v-else>
|
||||
Anzeigename, Handle, Plattform und Tier sind Pflicht.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[24px] border border-violet-100 bg-white p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<WandSparkles class="h-4 w-4 text-violet-700" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-600">Moderationsnotiz & Entscheidung</p>
|
||||
</div>
|
||||
|
||||
<label class="mt-4 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
|
||||
<textarea
|
||||
v-model="reviewForm.reviewNote"
|
||||
rows="5"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Warum wurde übernommen oder verworfen?"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" class="justify-center" @click="emit('reject', nomination.id)">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" class="justify-center" @click="emit('approve', nomination.id)">
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user