Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
-169
View File
@@ -1,169 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { Film, Link as LinkIcon, Sparkles, Star } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const clipUrl = ref('')
const title = ref('')
const creator = ref('')
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
const clipCategory = store.categories.categories.find((c) => /clip/i.test(c.name))
selectedCategoryId.value = clipCategory?.id ?? store.categories.categories[0]?.id ?? null
})
const categoryOptions = computed(() =>
store.categories.categories.map((category) => ({ label: category.name, value: category.id })),
)
const platform = computed(() => {
const url = clipUrl.value.trim().toLowerCase()
if (!url) return null
if (url.includes('twitch.tv') || url.includes('clips.twitch.tv')) return 'Twitch'
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'YouTube'
return 'unknown'
})
const urlValid = computed(() => platform.value === 'Twitch' || platform.value === 'YouTube')
async function submitClip() {
if (!urlValid.value) {
submitError.value = 'Bitte gib einen gültigen Twitch- oder YouTube-Link an.'
return
}
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
await store.submitClip({
year: store.categories.year,
categoryId: selectedCategoryId.value,
twitchUserId: authStore.session?.twitchUserId ?? '',
clipUrl: clipUrl.value.trim(),
title: title.value.trim(),
creator: creator.value.trim(),
})
submitMessage.value = 'Clip eingereicht! Das Team schaut ihn sich an. Danke fürs Teilen. 💜'
clipUrl.value = ''
title.value = ''
creator.value = ''
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Clip des Jahres"
title="Reich deinen Clip ein"
description="Der eine Moment, den die Community sehen muss? Teil den Link zu deinem Lieblings-Clip wir kümmern uns um den Rest."
:icon="Film"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Link einfügen</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Details</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Einreichen</span>
</div>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[1.1fr_0.9fr]">
<!-- Left: form -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Einreichung. 💜
</p>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Clip-Link (Twitch oder YouTube)</label>
<div class="flex items-center gap-2 rounded-2xl border bg-white px-4 py-3 transition"
:class="clipUrl && !urlValid ? 'border-rose-300' : 'border-violet-200'">
<LinkIcon class="h-4 w-4 shrink-0 text-violet-400" />
<input
v-model="clipUrl"
type="url"
class="w-full bg-transparent text-sm outline-none"
placeholder="https://clips.twitch.tv/… oder https://youtu.be/…"
/>
<span v-if="urlValid" class="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">{{ platform }}</span>
</div>
<p v-if="clipUrl && !urlValid" class="text-xs text-rose-500">Das sieht nicht nach einem Twitch- oder YouTube-Link aus.</p>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Titel (optional)</label>
<input v-model="title" type="text" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm" placeholder="Worum geht's im Clip?" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">VTuber im Clip (optional)</label>
<input v-model="creator" type="text" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm" placeholder="Name oder @handle" />
</div>
</div>
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
<Button class="w-full gap-2" :disabled="submitting || !authStore.isLoggedIn || !urlValid" @click="submitClip">
<Film class="h-4 w-4" />
{{ submitting ? 'Reicht ein ...' : 'Clip einreichen' }}
</Button>
</div>
<!-- Right: preview / rules -->
<div class="space-y-4">
<div class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Vorschau</p>
<div class="mt-3 grid aspect-video place-items-center rounded-xl bg-white/60">
<Film class="h-10 w-10 text-violet-400" />
</div>
<p class="mt-3 truncate text-sm font-semibold text-violet-800">{{ title || 'Dein Clip-Titel' }}</p>
<p class="truncate text-xs text-slate-500">{{ creator || 'VTuber im Clip' }} · {{ platform && urlValid ? platform : 'Link einfügen' }}</p>
</div>
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Twitch-Clips oder YouTube-Links werden akzeptiert.</p>
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Mehrere Clips? Einfach nacheinander einreichen.</p>
<p class="flex items-center gap-2"><Sparkles class="h-3.5 w-3.5 text-amber-400" /> Das Team prüft jede Einreichung vor der Voting-Phase.</p>
</div>
</div>
</div>
</Card>
</div>
</template>
+2 -702
View File
@@ -1,707 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import heroImg from '../assets/landing-hero-character.png'
const store = useAwardsStore()
const authStore = useAuthStore()
onMounted(() => void store.loadHomeData())
/* ---- Countdown ---- */
const pad = (n: number) => String(n).padStart(2, '0')
const cdD = ref('00'); const cdH = ref('00'); const cdM = ref('00'); const cdS = ref('00')
function tick() {
const target = new Date('2026-09-11T23:59:59').getTime()
const diff = Math.max(0, target - Date.now())
const s = Math.floor(diff / 1000)
cdD.value = pad(Math.floor(s / 86400))
cdH.value = pad(Math.floor((s % 86400) / 3600))
cdM.value = pad(Math.floor((s % 3600) / 60))
cdS.value = pad(s % 60)
}
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => { tick(); timer = setInterval(tick, 1000) })
onBeforeUnmount(() => { if (timer) clearInterval(timer) })
/* ---- Auth ---- */
async function doLogin() {
try {
await authStore.login({ twitchUserId: 'jayuhime_demo', displayName: 'Jayuhime', role: 'viewer' })
} catch {}
}
function requireLogin(cb: () => void) {
if (authStore.isLoggedIn) { cb(); return }
doLogin().then(cb)
}
/* ---- Vote modal ---- */
const modal = ref<null | 'vote' | 'nominate' | 'show'>(null)
const modalSubmitted = ref(false)
const successKind = ref<'vote' | 'show' | null>(null)
const activeCatIdx = ref(0)
const votes = reactive<Record<number, number>>({})
const CATS = [
{ id: 'year', name: 'VTuber des Jahres', icon: '✦' },
{ id: 'newcomer', name: 'Best Newcomer', icon: '★' },
{ id: 'design', name: 'Model & Design', icon: '✧' },
{ id: 'music', name: 'Gesang & Musik', icon: '♬' },
{ id: 'gaming', name: 'Best Gaming', icon: '⚔' },
{ id: 'variety', name: 'Best Variety', icon: '☻' },
{ id: 'community', name: 'Community Liebling', icon: '♡' },
{ id: 'collab', name: 'Best Collab & Duo', icon: '✶' },
]
const NOMS: Record<string, [string, string][]> = {
year: [['Akari ✩', '@akari_vt'], ['Nox ✦', '@nox_live'], ['Mochi ♡', '@mochi_mochi'], ['Yuki Stern', '@yuki_sings']],
newcomer: [['Nox ✦', '@nox_live'], ['Pixel ⚔', '@pixelpunk'], ['Lumi', '@lumi_vt'], ['Sora Blau', '@sora_blau']],
design: [['Mochi ♡', '@mochi_mochi'], ['Akari ✩', '@akari_vt'], ['Hana ♡', '@hana_hearts'], ['Rei Velvet', '@rei_velvet']],
music: [['Yuki Stern', '@yuki_sings'], ['Lumi', '@lumi_vt'], ['Melo Diva', '@melo_diva'], ['Hana ♡', '@hana_hearts']],
gaming: [['Pixel ⚔', '@pixelpunk'], ['Nox ✦', '@nox_live'], ['Kotaro', '@kotaro_plays'], ['Bit Knight', '@bit_knight']],
variety: [['Akari ✩', '@akari_vt'], ['Kotaro', '@kotaro_plays'], ['Mochi ♡', '@mochi_mochi'], ['Taro Chaos', '@taro_chaos']],
community: [['Hana ♡', '@hana_hearts'], ['Akari ✩', '@akari_vt'], ['Lumi', '@lumi_vt'], ['Sora Blau', '@sora_blau']],
collab: [['Akari & Nox', '@akari_vt'], ['Mochi & Hana', '@mochi_mochi'], ['Pixel & Kotaro', '@pixelpunk'], ['Yuki & Melo', '@yuki_sings']],
}
const activeCat = computed(() => CATS[activeCatIdx.value])
const activeNoms = computed(() => NOMS[activeCat.value.id] ?? [])
const voteCount = computed(() => Object.keys(votes).length)
function openModal(kind: 'vote' | 'nominate' | 'show') {
modal.value = kind
modalSubmitted.value = false
successKind.value = null
}
function closeModal() { modal.value = null; modalSubmitted.value = false }
function pickNominee(catId: string, idx: number) { votes[CATS.findIndex(c => c.id === catId)] = idx }
function submitVote() {
if (voteCount.value === 0) return
successKind.value = 'vote'
modalSubmitted.value = true
}
/* ---- Show reminder ---- */
const reminderEmail = ref('')
function submitReminder() { successKind.value = 'show'; modalSubmitted.value = true }
/* ---- Clip form ---- */
const clipCatIdx = ref(0)
const clipUrl = ref('')
const clipNom = ref('')
const clipDesc = ref('')
const clipDsgvo = ref(false)
const clipSubmitted = ref(false)
const homePrivacyOpen = ref(false)
function submitClip() {
if (!clipDsgvo.value || !clipUrl.value.trim()) return
clipSubmitted.value = true
}
/* ---- FAQ ---- */
const faq = [
{
q: 'Wer darf abstimmen?',
a: 'Alle, die einen Twitch-Account haben — einmal einloggen, fertig. Kein Extra-Konto, kein Newsletter-Abo, kein Papierkram.',
},
{
q: 'Wie oft kann ich abstimmen?',
a: 'Einmal pro Kategorie. Du kannst deine Stimme bis zum Ende der Voting-Phase jederzeit ändern — also kein Stress, wenn du dich umentscheidest.',
},
{
q: 'Wie werden die Gewinner ermittelt?',
a: 'Ausschließlich durch eure Stimmen. Keine Jury, keine versteckten Gewichtungen — wer die meisten Votes hat, gewinnt.',
},
{
q: 'Kann ich eine VTuberin/einen VTuber selbst nominieren?',
a: 'Ja! Während der Nominierungsphase kannst du beliebige Vorschläge einreichen. Das Jayuhime-Team kuratiert die finale Liste, um Duplikate und Spam herauszufiltern.',
},
{
q: 'Was passiert mit meinen Daten?',
a: 'Wir speichern nur deine Twitch-User-ID und die Zeitstempel deiner Aktionen. Alle Daten werden spätestens 6 Monate nach der Award-Show automatisch gelöscht. Keine Weitergabe an Dritte.',
},
]
import HomeLandingExperience from '../components/home/HomeLandingExperience.vue'
</script>
<template>
<!-- ===== HERO ===== -->
<header style="position:relative;overflow:hidden;min-height:780px;">
<!-- Accent bar -->
<div style="position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#ff5fa2,#a06bff,#5fa2ff);z-index:20;"></div>
<!-- Glow blobs -->
<div style="position:absolute;width:640px;height:640px;top:-80px;right:calc(50% - 190px);border-radius:50%;background:radial-gradient(circle,rgba(255,180,220,.48) 0%,transparent 70%);filter:blur(40px);pointer-events:none;animation:pulseGlow 7s ease-in-out infinite;"></div>
<div style="position:absolute;width:480px;height:480px;bottom:-120px;left:-60px;border-radius:50%;background:radial-gradient(circle,rgba(196,178,246,.4) 0%,transparent 70%);filter:blur(32px);pointer-events:none;animation:floaty2 9s ease-in-out infinite;"></div>
<!-- Confetti stars -->
<div style="position:absolute;top:18%;left:8%;font-size:13px;color:#d0b4f5;animation:twinkle 3.2s ease-in-out infinite;pointer-events:none;"></div>
<div style="position:absolute;top:32%;left:14%;font-size:9px;color:#f2b3d4;animation:twinkle 4.5s 1s ease-in-out infinite;pointer-events:none;"></div>
<div style="position:absolute;top:60%;left:6%;font-size:11px;color:#b9a3e8;animation:twinkle 5.1s 0.5s ease-in-out infinite;pointer-events:none;"></div>
<!-- Character image -->
<img
:src="heroImg"
alt=""
aria-hidden="true"
style="position:absolute;top:-10px;right:max(30px,calc(50% - 560px));height:1010px;width:auto;object-fit:contain;pointer-events:none;z-index:1;animation:floaty 6s ease-in-out infinite;"
/>
<!-- Left gradient overlay -->
<div style="position:absolute;inset:0;pointer-events:none;z-index:2;background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.86) 26%,rgba(246,240,254,.3) 46%,transparent 62%);"></div>
<!-- Content -->
<div style="position:relative;z-index:10;max-width:1200px;margin:0 auto;padding:80px 24px 96px;min-height:780px;display:flex;flex-direction:column;justify-content:center;">
<!-- H1 -->
<h1 style="margin:0;line-height:.92;letter-spacing:-.02em;">
<span style="display:block;font-family:'Fredoka',sans-serif;font-size:clamp(56px,8.4vw,116px);color:#5f44ad;font-weight:700;">VTUBER</span>
<span style="display:block;font-family:'Fredoka',sans-serif;font-size:clamp(56px,8.4vw,116px);font-weight:700;background:linear-gradient(95deg,#eeb24a,#d9942a);-webkit-background-clip:text;background-clip:text;color:transparent;">STAR AWARDS</span>
</h1>
<span style="font-family:'Sacramento',cursive;font-size:clamp(32px,3.8vw,48px);color:#8a6fd0;display:block;margin:8px 0 32px;transform:rotate(-3deg);transform-origin:left center;">Presented by Jayuhime</span>
<!-- Phase card -->
<div style="background:rgba(255,255,255,.46);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-radius:22px;padding:24px;border:1px solid rgba(255,255,255,.7);max-width:440px;box-shadow:0 8px 32px rgba(139,108,219,.12);">
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;margin-bottom:14px;"> {{ store.overview.currentPhase }}</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-size:20px;font-weight:700;color:#3f3556;margin:0 0 8px;">Abstimmen · Bis 11. September</h2>
<p style="font-size:14px;color:#7d7491;margin:0 0 20px;line-height:1.55;">Deine Stimme entscheidet, wer auf die Bühne darf. Jede Kategorie zählt.</p>
<!-- Countdown -->
<div style="display:flex;align-items:center;background:rgba(255,255,255,.6);border-radius:14px;overflow:hidden;border:1px solid #ede4fb;margin-bottom:16px;">
<div style="flex:1;text-align:center;padding:12px 4px 10px;border-right:1px solid #eee4fb;">
<div style="font-family:'Fredoka',sans-serif;font-size:32px;font-weight:700;color:#5a4a8a;line-height:1;">{{ cdD }}</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a98ddb;margin-top:4px;">Tage</div>
</div>
<div style="flex:1;text-align:center;padding:12px 4px 10px;border-right:1px solid #eee4fb;">
<div style="font-family:'Fredoka',sans-serif;font-size:32px;font-weight:700;color:#5a4a8a;line-height:1;">{{ cdH }}</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a98ddb;margin-top:4px;">Std</div>
</div>
<div style="flex:1;text-align:center;padding:12px 4px 10px;border-right:1px solid #eee4fb;">
<div style="font-family:'Fredoka',sans-serif;font-size:32px;font-weight:700;color:#5a4a8a;line-height:1;">{{ cdM }}</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a98ddb;margin-top:4px;">Min</div>
</div>
<div style="flex:1;text-align:center;padding:12px 4px 10px;">
<div style="font-family:'Fredoka',sans-serif;font-size:32px;font-weight:700;color:#c77ab0;line-height:1;">{{ cdS }}</div>
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a98ddb;margin-top:4px;">Sek</div>
</div>
</div>
<!-- CTA -->
<button
@click="requireLogin(() => openModal('vote'))"
style="width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
Jetzt abstimmen
</button>
</div>
</div>
<!-- Host card -->
<div style="position:absolute;z-index:61;right:64px;bottom:96px;background:rgba(255,255,255,.36);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:18px;border:1px solid rgba(255,255,255,.7);padding:16px 20px;box-shadow:0 16px 36px rgba(120,90,196,.2);display:flex;align-items:center;gap:12px;">
<div style="width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#ff5fa2,#a06bff);display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;flex:none;"></div>
<div>
<div style="font-family:'Fredoka',sans-serif;font-size:15px;font-weight:600;color:#3f3556;">Jayuhime</div>
<div style="font-size:12px;color:#9a8fb5;">Host & Organisatorin</div>
</div>
</div>
</header>
<!-- ===== STREAM BANNER ===== -->
<section style="background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);padding:28px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;gap:20px;flex-wrap:wrap;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:16px;min-width:0;">
<div style="flex:none;width:48px;height:48px;border-radius:14px;background:#9146FF;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(145,70,255,.4);">
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/>
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/>
</svg>
</div>
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:rgba(255,255,255,.5);margin-bottom:2px;">Award Show</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(20px,3vw,30px);color:#fff;font-weight:600;margin:0 0 2px;">Live ab 12. September 2026</h2>
<p style="font-size:14px;color:rgba(255,255,255,.65);margin:0;">Die große Award-Show auf Twitch sei live dabei.</p>
</div>
</div>
<button
@click="openModal('show')"
style="flex:none;display:inline-flex;align-items:center;gap:8px;padding:13px 22px;border-radius:12px;border:none;background:rgba(255,255,255,.12);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.2);"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
Erinnerung aktivieren
</button>
</div>
</section>
<!-- ===== STATS ===== -->
<section style="background:#ebe2f8;padding:28px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:center;gap:0;flex-wrap:wrap;">
<div style="flex:1;min-width:160px;text-align:center;padding:16px 24px;border-right:1px solid rgba(124,86,196,.18);">
<div style="font-family:'Fredoka',sans-serif;font-size:clamp(32px,4vw,48px);font-weight:700;background:linear-gradient(135deg,#8b6cdb,#b78bff);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1;">12.341</div>
<div style="font-size:14px;color:#8a8398;font-weight:500;margin-top:4px;">Nominierungen</div>
</div>
<div style="flex:1;min-width:160px;text-align:center;padding:16px 24px;border-right:1px solid rgba(124,86,196,.18);">
<div style="font-family:'Fredoka',sans-serif;font-size:clamp(32px,4vw,48px);font-weight:700;background:linear-gradient(135deg,#8b6cdb,#b78bff);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1;">587.231</div>
<div style="font-size:14px;color:#8a8398;font-weight:500;margin-top:4px;">Abgegebene Stimmen</div>
</div>
<div style="flex:1;min-width:160px;text-align:center;padding:16px 24px;">
<div style="font-family:'Fredoka',sans-serif;font-size:clamp(32px,4vw,48px);font-weight:700;background:linear-gradient(135deg,#8b6cdb,#b78bff);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1;">8</div>
<div style="font-size:14px;color:#8a8398;font-weight:500;margin-top:4px;">Kategorien</div>
</div>
</div>
</section>
<!-- ===== ABLAUF ===== -->
<section style="padding:80px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
<div style="text-align:center;margin-bottom:56px;">
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:#f3eefb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">Zeitplan</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(28px,4vw,44px);color:#3f3556;margin:0 0 12px;font-weight:700;">So läuft's ab</h2>
<p style="font-size:17px;color:#8a8398;margin:0 auto;max-width:520px;line-height:1.6;">Drei Phasen, eine große Show — von der ersten Nominierung bis zum Finale auf Twitch.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:24px;">
<!-- Phase 1 -->
<div style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:24px;padding:32px 28px;position:relative;">
<div style="display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:16px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-size:24px;margin-bottom:20px;box-shadow:0 8px 20px rgba(255,95,162,.3);">★</div>
<div style="font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:8px;">Phase 1</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-size:22px;font-weight:700;color:#3f3556;margin:0 0 6px;">Nominierung</h3>
<p style="font-size:13px;color:#9a93a8;margin:0 0 16px;">1. Mai 31. Mai 2026</p>
<p style="font-size:14px;color:#7d7491;line-height:1.6;margin:0 0 18px;">Schlage deine Favorit:innen vor — in allen Kategorien. Jede:r mit Twitch-Account kann nominieren.</p>
<span style="display:inline-block;padding:4px 12px;border-radius:999px;background:#f0fdf4;color:#16a34a;font-size:11px;font-weight:700;letter-spacing:.5px;">✓ Abgeschlossen</span>
</div>
<!-- Phase 2 -->
<div style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:24px;padding:32px 28px;position:relative;">
<div style="display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:16px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-size:24px;margin-bottom:20px;box-shadow:0 8px 20px rgba(124,86,196,.3);">✦</div>
<div style="font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:8px;">Phase 2</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-size:22px;font-weight:700;color:#3f3556;margin:0 0 6px;">Community Voting</h3>
<p style="font-size:13px;color:#9a93a8;margin:0 0 16px;">1. Juni 11. September 2026</p>
<p style="font-size:14px;color:#7d7491;line-height:1.6;margin:0 0 18px;">Stimme in jeder Kategorie für deinen Favoriten. Eine Stimme pro Kategorie, jederzeit änderbar.</p>
<span style="display:inline-block;padding:4px 12px;border-radius:999px;background:linear-gradient(135deg,#8b6cdb,#a06bff);color:#fff;font-size:11px;font-weight:700;letter-spacing:.5px;">◉ Jetzt aktiv</span>
</div>
<!-- Phase 3 -->
<div style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:24px;padding:32px 28px;position:relative;">
<div style="display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:16px;background:linear-gradient(135deg,#d9942a,#eeb24a);color:#fff;font-size:24px;margin-bottom:20px;box-shadow:0 8px 20px rgba(217,148,42,.3);">♡</div>
<div style="font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:8px;">Phase 3</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-size:22px;font-weight:700;color:#3f3556;margin:0 0 6px;">Award Show</h3>
<p style="font-size:13px;color:#9a93a8;margin:0 0 16px;">12. September 2026 · 20:00 Uhr</p>
<p style="font-size:14px;color:#7d7491;line-height:1.6;margin:0 0 18px;">Die Gewinner werden live auf Twitch bei Jayuhime bekannt gegeben. Sei dabei!</p>
<span style="display:inline-block;padding:4px 12px;border-radius:999px;background:#f5f5f5;color:#9a93a8;font-size:11px;font-weight:700;letter-spacing:.5px;">○ Demnächst</span>
</div>
</div>
</div>
</section>
<!-- ===== KATEGORIEN ===== -->
<section style="padding:70px 0 80px;background:#f9f6fe;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
<div style="text-align:center;margin-bottom:48px;">
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:#f3eefb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">Teilnehmen & Abstimmen</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(32px,4vw,48px);color:#3f3556;margin:0 0 14px;font-weight:700;letter-spacing:-.4px;">8 Kategorien · 1 Sternenhimmel</h2>
<p style="font-size:17px;color:#8a8398;margin:0 auto;max-width:520px;line-height:1.6;">Alle Kategorien sind offen — nominier deine Lieblinge und gib ihnen deine Stimme.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:20px;">
<div
v-for="cat in CATS"
:key="cat.id"
style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:22px;padding:28px 24px;cursor:pointer;transition:transform .15s,box-shadow .15s;"
@click="requireLogin(() => openModal('vote'))"
>
<div style="display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:13px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-size:20px;margin-bottom:16px;box-shadow:0 6px 14px rgba(255,95,162,.25);">{{ cat.icon }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;color:#3f3556;margin:0 0 6px;">{{ cat.name }}</h3>
<p style="font-size:13px;color:#9a93a8;margin:0;">Jetzt abstimmen →</p>
</div>
</div>
</div>
</section>
<!-- ===== NOMINIERTE ===== -->
<section style="background:#ebe2f8;padding:80px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
<div style="text-align:center;margin-bottom:48px;">
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:rgba(139,108,219,.15);color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">Saison 2026</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(28px,4vw,44px);color:#3f3556;margin:0 0 12px;font-weight:700;">Nominierte</h2>
<p style="font-size:17px;color:#8a8398;margin:0 auto;max-width:480px;line-height:1.6;">Die Kandidat:innen stehen fest — jetzt liegt es an dir.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;">
<div
v-for="cand in (store.categories.categories[0]?.candidates ?? []).slice(0, 4)"
:key="cand.id"
style="background:#fff;border-radius:24px;overflow:hidden;border:1px solid rgba(124,86,196,.12);padding:0;"
>
<div style="height:8px;background:linear-gradient(90deg,#ff5fa2,#a06bff);"></div>
<div style="padding:24px 20px;">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:16px;">
<div style="flex:none;width:52px;height:52px;border-radius:14px;background:repeating-linear-gradient(45deg,#f3eefb 0 8px,#ece2fa 8px 16px);display:flex;align-items:center;justify-content:center;font-size:22px;color:#a98ddb;">✦</div>
<div style="min-width:0;">
<div style="font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ cand.displayName }}</div>
<div style="font-size:13px;color:#9a93a8;">{{ cand.channelSlug }}</div>
</div>
</div>
<div style="font-size:12px;color:#a98ddb;font-weight:600;text-transform:uppercase;letter-spacing:.8px;margin-bottom:12px;">{{ store.categories.categories[0]?.name }}</div>
<button
@click="requireLogin(() => openModal('vote'))"
style="width:100%;padding:11px;border-radius:999px;border:none;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;box-shadow:0 6px 16px rgba(255,95,162,.3);"
>Abstimmen ✦</button>
</div>
</div>
</div>
<div style="text-align:center;margin-top:36px;">
<button
@click="requireLogin(() => openModal('nominate'))"
style="display:inline-flex;align-items:center;gap:8px;padding:13px 24px;border-radius:12px;border:1.5px solid rgba(124,86,196,.3);background:transparent;color:#8b6cdb;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;cursor:pointer;"
>Alle Nominierten anzeigen →</button>
</div>
</div>
</section>
<!-- ===== CLIPS ===== -->
<section style="padding:80px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:64px;align-items:start;">
<!-- Left -->
<div>
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:#f3eefb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:16px;">Clip-Einreichung</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-size:clamp(30px,3.6vw,44px);color:#3f3556;margin:0 0 16px;font-weight:700;line-height:1.15;">Einen Clip einreichen</h2>
<p style="font-size:15.5px;color:#7d7491;line-height:1.65;margin:0 0 32px;">Hast du einen unvergesslichen Moment auf Twitch oder YouTube gesehen? Reiche ihn ein und lass die Community entscheiden.</p>
<div style="display:flex;flex-direction:column;gap:20px;">
<div style="display:flex;align-items:flex-start;gap:16px;">
<div style="flex:none;width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;">1</div>
<div>
<div style="font-weight:700;color:#3f3556;font-size:14px;margin-bottom:4px;">Kategorie wählen</div>
<div style="font-size:13.5px;color:#8a8398;line-height:1.55;">Ordne den Clip der passenden Award-Kategorie zu.</div>
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:16px;">
<div style="flex:none;width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;">2</div>
<div>
<div style="font-weight:700;color:#3f3556;font-size:14px;margin-bottom:4px;">Link einfügen</div>
<div style="font-size:13.5px;color:#8a8398;line-height:1.55;">Twitch-Clip oder YouTube-Link — beides ist willkommen.</div>
</div>
</div>
<div style="display:flex;align-items:flex-start;gap:16px;">
<div style="flex:none;width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#d9942a,#eeb24a);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;">3</div>
<div>
<div style="font-weight:700;color:#3f3556;font-size:14px;margin-bottom:4px;">Einreichen & fertig</div>
<div style="font-size:13.5px;color:#8a8398;line-height:1.55;">Das Team prüft deinen Clip und gibt ihn frei.</div>
</div>
</div>
</div>
</div>
<!-- Right: Form -->
<div>
<template v-if="!clipSubmitted">
<div style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:24px;padding:32px 28px;">
<div style="display:flex;flex-direction:column;gap:16px;">
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;letter-spacing:.3px;">Kategorie</label>
<select v-model="clipCatIdx" style="width:100%;padding:13px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;cursor:pointer;">
<option v-for="(cat, i) in CATS" :key="cat.id" :value="i">{{ cat.icon }}&nbsp;{{ cat.name }}</option>
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;letter-spacing:.3px;">Twitch- oder YouTube-Link</label>
<input v-model="clipUrl" type="url" placeholder="https://clips.twitch.tv/..." style="width:100%;padding:13px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;box-sizing:border-box;" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;letter-spacing:.3px;">Name des VTubers</label>
<input v-model="clipNom" type="text" placeholder="z. B. Hoshimi Miyu" style="width:100%;padding:13px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;box-sizing:border-box;" />
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;letter-spacing:.3px;">Kurze Beschreibung</label>
<textarea v-model="clipDesc" rows="3" placeholder="Warum ist dieser Clip besonders?" style="width:100%;padding:13px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;resize:vertical;box-sizing:border-box;"></textarea>
</div>
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;font-size:13px;color:#7d7491;line-height:1.5;">
<input type="checkbox" v-model="clipDsgvo" style="margin-top:2px;accent-color:#8b6cdb;flex:none;" />
Ich stimme der Verarbeitung meiner Daten gemäß der <button type="button" @click.stop="homePrivacyOpen = true" style="background:none;border:none;padding:0;color:#8b6cdb;cursor:pointer;font-size:inherit;">Datenschutzerklärung</button> zu.
</label>
<button
@click="requireLogin(submitClip)"
:style="clipDsgvo ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Clip einreichen
</button>
</div>
</div>
</template>
<template v-else>
<div style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:24px;padding:56px 40px;text-align:center;">
<div style="width:72px;height:72px;margin:0 auto 22px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 14px 32px rgba(31,157,90,.32);">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 12px;color:#3f3556;">Clip eingereicht ✦</h3>
<p style="font-size:16px;line-height:1.6;color:#7d7491;max-width:320px;margin:0 auto 28px;">Danke! Dein Clip wird vom Team geprüft und bei Freigabe in der Abstimmung angezeigt.</p>
<button @click="clipSubmitted = false; clipUrl = ''; clipNom = ''; clipDesc = ''; clipDsgvo = false" style="padding:14px 32px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;">Weiteren Clip einreichen</button>
</div>
</template>
</div>
</div>
</div>
</section>
<!-- ===== VOTING CTA ===== -->
<section style="padding:0 0 80px;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
<!-- How it works -->
<div style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:28px;padding:48px 40px;margin-bottom:32px;">
<div style="text-align:center;margin-bottom:40px;">
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:#f3eefb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">Wie es funktioniert</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(26px,3.5vw,40px);color:#3f3556;margin:0 0 10px;font-weight:700;">So stimmst du ab</h2>
<p style="font-size:16px;color:#8a8398;margin:0;line-height:1.6;">Drei einfache Schritte — keine App nötig.</p>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:32px;">
<div style="text-align:center;">
<div style="background:#f6f1fd;border:1px dashed #d8c9f2;height:170px;border-radius:12px;margin-bottom:20px;display:flex;align-items:center;justify-content:center;font-size:48px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d8c9f2" stroke-width="1.5"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4z"/></svg>
</div>
<div style="font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:8px;">Schritt 1</div>
<h3 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:700;color:#3f3556;margin:0 0 6px;">Mit Twitch anmelden</h3>
<p style="font-size:13.5px;color:#8a8398;line-height:1.55;margin:0;">Einmal einloggen — kein extra Konto nötig.</p>
</div>
<div style="text-align:center;">
<div style="background:#f6f1fd;border:1px dashed #d8c9f2;height:170px;border-radius:12px;margin-bottom:20px;display:flex;align-items:center;justify-content:center;font-size:48px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d8c9f2" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</div>
<div style="font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:8px;">Schritt 2</div>
<h3 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:700;color:#3f3556;margin:0 0 6px;">Kategorien durchgehen</h3>
<p style="font-size:13.5px;color:#8a8398;line-height:1.55;margin:0;">Pro Kategorie eine Stimme — jederzeit änderbar.</p>
</div>
<div style="text-align:center;">
<div style="background:#f6f1fd;border:1px dashed #d8c9f2;height:170px;border-radius:12px;margin-bottom:20px;display:flex;align-items:center;justify-content:center;font-size:48px;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d8c9f2" stroke-width="1.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div style="font-size:11px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:8px;">Schritt 3</div>
<h3 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:700;color:#3f3556;margin:0 0 6px;">Stimme absenden</h3>
<p style="font-size:13.5px;color:#8a8398;line-height:1.55;margin:0;">Bestätigen und auf die Show freuen!</p>
</div>
</div>
</div>
<!-- Big CTA -->
<div style="background:linear-gradient(135deg,#8b6cdb,#b78bff);border-radius:28px;padding:56px 48px;text-align:center;position:relative;overflow:hidden;">
<div style="position:absolute;top:-40px;right:-40px;width:200px;height:200px;border-radius:50%;background:rgba(255,255,255,.08);pointer-events:none;"></div>
<div style="position:absolute;bottom:-30px;left:-30px;width:150px;height:150px;border-radius:50%;background:rgba(255,255,255,.06);pointer-events:none;"></div>
<div style="position:relative;z-index:1;">
<div style="font-size:42px;margin-bottom:12px;">✦</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(28px,4vw,44px);color:#fff;margin:0 0 12px;font-weight:700;">Deine Stimme zählt</h2>
<p style="font-size:17px;color:rgba(255,255,255,.82);margin:0 auto 32px;max-width:480px;line-height:1.6;">Wähle jetzt deine Favorit:innen — jede Kategorie, eine Stimme. Die Community entscheidet.</p>
<button
@click="requireLogin(() => openModal('vote'))"
style="display:inline-flex;align-items:center;gap:10px;padding:18px 36px;border-radius:14px;border:none;background:#fff;color:#7355c8;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;cursor:pointer;box-shadow:0 14px 36px rgba(0,0,0,.2);"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
Jetzt abstimmen ✦
</button>
</div>
</div>
</div>
</section>
<!-- ===== COMMUNITY ===== -->
<section style="background:linear-gradient(180deg,#ece3fa 0%,#e6dcf6 100%);padding:80px 0;overflow:hidden;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;display:grid;grid-template-columns:1fr auto;gap:48px;align-items:center;">
<div>
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:rgba(139,108,219,.15);color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:16px;">Community</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(28px,4vw,44px);color:#3f3556;margin:0 0 14px;font-weight:700;">Teile die Begeisterung</h2>
<p style="font-size:16px;color:#7d7491;line-height:1.65;margin:0 0 28px;max-width:460px;">Bring deine Community mit ins Boot! Je mehr Stimmen, desto mehr Gewicht hat das Ergebnis. Teile die Awards und animier andere zur Teilnahme.</p>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<button style="display:inline-flex;align-items:center;gap:8px;padding:13px 22px;border-radius:12px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;cursor:pointer;box-shadow:0 6px 18px rgba(124,86,196,.3);">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
Link teilen
</button>
<a href="https://twitch.tv/jayuhime" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:8px;padding:13px 22px;border-radius:12px;border:1.5px solid rgba(139,108,219,.3);background:transparent;color:#8b6cdb;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;text-decoration:none;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="#9146FF"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/></svg>
Jayuhime folgen
</a>
</div>
</div>
<div style="flex:none;">
<img :src="heroImg" alt="Jayuhime" style="height:360px;width:auto;object-fit:contain;filter:drop-shadow(0 20px 48px rgba(139,108,219,.3));animation:floaty 7s ease-in-out infinite;" />
</div>
</div>
</section>
<!-- ===== FAQ ===== -->
<section style="padding:80px 0;">
<div style="max-width:880px;margin:0 auto;padding:0 24px;">
<div style="text-align:center;margin-bottom:48px;">
<div style="display:inline-block;padding:4px 14px;border-radius:999px;background:#f3eefb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">Häufige Fragen</div>
<h2 style="font-family:'Fredoka',sans-serif;font-size:clamp(28px,4vw,44px);color:#3f3556;margin:0 0 12px;font-weight:700;">FAQ</h2>
<p style="font-size:17px;color:#8a8398;margin:0;line-height:1.6;">Alles, was du wissen musst auf einen Blick.</p>
</div>
<div style="display:flex;flex-direction:column;gap:12px;">
<details
v-for="item in faq"
:key="item.q"
style="background:#fff;border:1px solid rgba(124,86,196,.16);border-radius:16px;overflow:hidden;"
>
<summary style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px;cursor:pointer;font-weight:600;font-size:15.5px;color:#3f3556;user-select:none;">
{{ item.q }}
<span style="flex:none;width:26px;height:26px;border-radius:50%;background:#f3eefb;color:#8b6cdb;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;">+</span>
</summary>
<div style="padding:0 24px 20px;font-size:14.5px;color:#7d7491;line-height:1.65;">{{ item.a }}</div>
</details>
</div>
</div>
</section>
<!-- ===== FOOTER ===== -->
<footer style="border-top:1px solid rgba(124,86,196,.16);padding:40px 0;">
<div style="max-width:1200px;margin:0 auto;padding:0 24px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:16px 32px;">
<div style="display:flex;align-items:center;gap:10px;text-decoration:none;">
<div style="width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#8b6cdb,#e7b13e);display:flex;align-items:center;justify-content:center;color:#fff;font-size:16px;"></div>
<span style="font-family:'Fredoka',sans-serif;font-size:15px;font-weight:600;color:#3f3556;">VTuber Star Award 2026</span>
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px;font-size:14px;font-weight:500;">
<a href="#" style="color:#8a8398;text-decoration:none;">Impressum</a>
<a href="#" style="color:#8a8398;text-decoration:none;">Datenschutz</a>
<a href="#" style="color:#8a8398;text-decoration:none;">Kontakt</a>
<a href="#" style="color:#8a8398;text-decoration:none;">Sponsoren & Partner</a>
</div>
<div style="font-size:13px;color:#c9b8da;">© 2026 · Made with &amp; Chaos</div>
</div>
</footer>
<!-- ===== INLINE PRIVACY MODAL (clips DSGVO link) ===== -->
<Teleport to="body">
<div
v-if="homePrivacyOpen"
@click.self="homePrivacyOpen = false"
style="position:fixed;inset:0;z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
>
<div style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
<div>
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Rechtliches</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
</div>
<button @click="homePrivacyOpen = false" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;"></button>
</div>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
<p>Bei Teilnahme verarbeiten wir ausschließlich deine <strong style="color:#3f3556;">Twitch-User-ID</strong> sowie den Zeitstempel deiner Aktion. Alle Daten werden spätestens 6 Monate nach der Award-Show (bis März 2027) automatisch gelöscht.</p>
<p>Für die vollständige Erklärung melde dich an und öffne dein Profil.</p>
</div>
</div>
</div>
</Teleport>
<!-- ===== VOTE / NOMINATE / SHOW MODAL ===== -->
<Teleport to="body">
<div
v-if="modal"
@click.self="closeModal"
style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
>
<div style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
<button
@click="closeModal"
aria-label="Schliessen"
style="position:absolute;top:16px;right:16px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;"
></button>
<!-- Success -->
<template v-if="modalSubmitted">
<div style="padding:56px 40px;text-align:center;">
<div style="width:72px;height:72px;margin:0 auto 22px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 14px 32px rgba(31,157,90,.32);">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 12px;color:#3f3556;">
{{ successKind === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩' }}
</h3>
<p style="font-size:16px;line-height:1.6;color:#7d7491;max-width:420px;margin:0 auto 28px;">
{{ successKind === 'show' ? 'Wir erinnern dich rechtzeitig vor der Award-Show. Bis zum 12. September!' : 'Danke fürs Abstimmen! Du kannst deine Auswahl bis zum Ende der Phase ändern.' }}
</p>
<button @click="closeModal" style="padding:14px 32px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);">Schliessen</button>
</div>
</template>
<!-- Show reminder -->
<template v-else-if="modal === 'show'">
<div style="padding:38px 40px 40px;">
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 10px;color:#3f3556;">Sei live dabei </h3>
<p style="font-size:15.5px;line-height:1.6;color:#7d7491;margin:0 0 24px;">Die große Live-Show findet am <strong style="color:#5f44ad;">12. September 2026 um 20:00 Uhr</strong> auf Twitch statt. Aktiviere eine Erinnerung, damit du nichts verpasst.</p>
<div style="display:flex;gap:10px;margin-bottom:14px;">
<input v-model="reminderEmail" type="email" placeholder="deine@email.de" style="flex:1;padding:14px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;" />
<button @click="submitReminder" style="flex:none;padding:14px 24px;border-radius:12px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 22px rgba(124,86,196,.3);">Erinnern</button>
</div>
<a href="https://twitch.tv/jayuhime" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:9px;padding:14px;border-radius:12px;background:#f1ecfb;color:#7a3fd0;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;">
<svg width="17" height="17" viewBox="0 0 24 24" fill="#9146FF"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
Jayuhime auf Twitch folgen
</a>
</div>
</template>
<!-- Vote / Nominate picker -->
<template v-else>
<div style="display:flex;flex-direction:column;min-height:0;">
<div style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;">
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:27px;margin:0 0 6px;color:#3f3556;">
{{ modal === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt' }}
</h3>
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">
{{ modal === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.' }}
</p>
</div>
<div style="display:grid;grid-template-columns:230px 1fr;min-height:0;flex:1;overflow:hidden;">
<!-- Category list -->
<div style="border-right:1px solid #f1ecfb;padding:16px 14px;overflow-y:auto;display:flex;flex-direction:column;gap:4px;background:#fcfaff;">
<button
v-for="(cat, i) in CATS"
:key="cat.id"
@click="activeCatIdx = i"
:style="i === activeCatIdx
? 'display:flex;align-items:center;gap:10px;padding:11px 13px;border-radius:11px;cursor:pointer;font-size:14px;font-weight:600;border:1px solid #d8c9f2;background:#f1ecfb;color:#5f44ad;width:100%;text-align:left;font-family:\'Outfit\',sans-serif;outline:none;'
: 'display:flex;align-items:center;gap:10px;padding:11px 13px;border-radius:11px;cursor:pointer;font-size:14px;font-weight:500;border:1px solid transparent;background:transparent;color:#6f6685;width:100%;text-align:left;font-family:\'Outfit\',sans-serif;outline:none;'"
>
<span :style="i === activeCatIdx ? 'flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;background:#efe7fb;color:#9a7fce;'">{{ cat.icon }}</span>
<span>{{ cat.name }}</span>
<span v-if="votes[i] != null" style="flex:none;margin-left:auto;color:#1f9d5a;font-size:14px;font-weight:700;"></span>
</button>
</div>
<!-- Nominee list -->
<div style="padding:18px 20px;overflow-y:auto;display:flex;flex-direction:column;gap:10px;">
<div style="font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:#a98ddb;margin-bottom:2px;">{{ activeCat.name }}</div>
<div
v-for="(nom, idx) in activeNoms"
:key="idx"
:style="votes[activeCatIdx] === idx
? 'display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;border:1.5px solid #8b6cdb;background:#f6f1fd;'
: 'display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;border:1.5px solid #ece4f6;background:#fff;'"
>
<div style="display:flex;align-items:center;gap:12px;min-width:0;">
<div style="flex:none;width:42px;height:42px;border-radius:11px;background:repeating-linear-gradient(45deg,#f3eefb 0 8px,#ece2fa 8px 16px);"></div>
<div style="min-width:0;">
<div style="font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ nom[0] }}</div>
<div style="font-size:13px;color:#9a93a8;">{{ nom[1] }}</div>
</div>
</div>
<button
v-if="modal === 'vote'"
@click="pickNominee(activeCat.id, idx)"
:style="votes[activeCatIdx] === idx
? 'flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:13px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;'
: 'flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:13px;background:#f1ecfb;color:#8b6cdb;'"
>{{ votes[activeCatIdx] === idx ? '✓ Gewählt' : 'Auswählen' }}</button>
</div>
</div>
</div>
<!-- Submit bar -->
<div v-if="modal === 'vote'" style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 36px;border-top:1px solid #f1ecfb;background:#fcfaff;">
<div style="font-size:14px;font-weight:600;color:#7d7491;"><span style="color:#8b6cdb;font-weight:700;">{{ voteCount }}</span> / {{ CATS.length }} Kategorien gewählt</div>
<button
@click="submitVote"
style="padding:13px 28px;border-radius:12px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 22px rgba(124,86,196,.3);"
>Stimmen absenden </button>
</div>
</div>
</template>
</div>
</div>
</Teleport>
<HomeLandingExperience />
</template>
+363
View File
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { ApiRequestError } from '../lib/http'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const form = reactive({
login: 'jayuhime_admin',
password: '',
})
const errorMessage = ref('')
const redirectTarget = computed(() => {
const redirect = Array.isArray(route.query.redirect)
? route.query.redirect[0]
: route.query.redirect
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('/login')) {
return '/admin'
}
return redirect
})
async function submitLogin() {
errorMessage.value = ''
try {
await authStore.demoLogin({
login: form.login,
password: form.password,
})
await router.replace(redirectTarget.value)
} catch (error) {
if (error instanceof ApiRequestError && error.status === 404) {
errorMessage.value = 'Der Demo-Login ist auf diesem Deployment nicht aktiviert.'
return
}
if (error instanceof ApiRequestError && error.status === 503) {
errorMessage.value = 'Der Demo-Login ist im Backend noch nicht vollständig konfiguriert.'
return
}
errorMessage.value = 'Login oder Passwort stimmt nicht.'
}
}
</script>
<template>
<main class="login-page">
<div class="login-page__stars" aria-hidden="true">
<span class="login-page__star login-page__star--one"></span>
<span class="login-page__star login-page__star--two"></span>
<span class="login-page__star login-page__star--three"></span>
<span class="login-page__star login-page__star--four"></span>
</div>
<RouterLink to="/" class="login-page__brand" aria-label="Zur Landingpage">
<span class="login-page__brand-icon"></span>
<span>VTuber Star Award</span>
</RouterLink>
<section class="login-card" aria-labelledby="login-title">
<div class="login-card__eyebrow">Demo Zugang</div>
<h1 id="login-title">Admin Login</h1>
<p>
Melde dich mit den Demo-Credentials an, um das Admin-Panel für die Präsentation zu öffnen.
</p>
<form class="login-form" @submit.prevent="submitLogin">
<label>
<span>Login</span>
<input
v-model.trim="form.login"
type="text"
autocomplete="username"
required
placeholder="E-Mail oder Username"
>
</label>
<label>
<span>Passwort</span>
<input
v-model="form.password"
type="password"
autocomplete="current-password"
required
placeholder="Demo-Passwort eingeben"
>
</label>
<p v-if="errorMessage" class="login-form__error" role="alert">
{{ errorMessage }}
</p>
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
<span v-if="authStore.loading">Sternentor öffnet...</span>
<span v-else>Admin Panel öffnen</span>
</button>
</form>
<div class="login-card__footer">
<RouterLink to="/">Zurück zur Landingpage</RouterLink>
<span>Demo-Modus</span>
</div>
</section>
</main>
</template>
<style scoped>
.login-page {
position: relative;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
padding: 32px 18px;
background:
radial-gradient(circle at 18% 24%, rgba(255, 221, 238, 0.9), transparent 26%),
radial-gradient(circle at 82% 20%, rgba(210, 190, 255, 0.75), transparent 28%),
radial-gradient(circle at 50% 100%, rgba(255, 220, 151, 0.38), transparent 32%),
linear-gradient(145deg, #fbf6ff 0%, #f2e9ff 48%, #fff8f1 100%);
color: #3f3556;
font-family: 'Outfit', sans-serif;
}
.login-page::before {
content: "";
position: absolute;
inset: 11% 8% auto;
height: 330px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(139, 108, 219, 0.14), rgba(231, 177, 62, 0.16), rgba(247, 108, 173, 0.12));
filter: blur(42px);
transform: rotate(-6deg);
}
.login-page__brand {
position: absolute;
top: 28px;
left: 32px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 10px;
color: #3f3556;
text-decoration: none;
font-family: 'Fredoka', sans-serif;
font-weight: 700;
font-size: 18px;
}
.login-page__brand-icon {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 13px;
color: #fff;
background: linear-gradient(135deg, #8b6cdb, #e7b13e);
box-shadow: 0 14px 30px rgba(139, 108, 219, 0.26);
}
.login-page__stars {
position: absolute;
inset: 0;
pointer-events: none;
}
.login-page__star {
position: absolute;
color: rgba(139, 108, 219, 0.7);
text-shadow: 0 0 22px rgba(139, 108, 219, 0.4);
animation: loginTwinkle 3s ease-in-out infinite;
}
.login-page__star--one {
top: 18%;
left: 22%;
font-size: 34px;
}
.login-page__star--two {
top: 21%;
right: 20%;
font-size: 28px;
animation-delay: 0.8s;
}
.login-page__star--three {
bottom: 20%;
left: 18%;
font-size: 22px;
color: rgba(231, 177, 62, 0.72);
animation-delay: 1.3s;
}
.login-page__star--four {
right: 17%;
bottom: 24%;
font-size: 24px;
color: rgba(247, 108, 173, 0.62);
animation-delay: 1.9s;
}
.login-card {
position: relative;
z-index: 1;
width: min(100%, 520px);
padding: 42px;
border: 1px solid rgba(139, 108, 219, 0.18);
border-radius: 38px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 28px 80px rgba(76, 48, 119, 0.16);
backdrop-filter: blur(22px);
}
.login-card__eyebrow {
color: #8b6cdb;
font-size: 13px;
font-weight: 900;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.login-card h1 {
margin: 10px 0 12px;
font-family: 'Playfair Display', serif;
font-size: clamp(42px, 8vw, 70px);
line-height: 0.95;
color: #3f3556;
}
.login-card p {
margin: 0;
color: #736987;
font-size: 17px;
line-height: 1.65;
}
.login-form {
display: grid;
gap: 18px;
margin-top: 30px;
}
.login-form label {
display: grid;
gap: 9px;
color: #65748b;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.login-form input {
width: 100%;
box-sizing: border-box;
border: 1.5px solid #ded4ff;
border-radius: 22px;
padding: 17px 18px;
background: rgba(255, 255, 255, 0.82);
color: #3f3556;
font: 700 17px 'Outfit', sans-serif;
outline: none;
box-shadow: 0 12px 32px rgba(139, 108, 219, 0.06);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.login-form input:focus {
border-color: #8b6cdb;
box-shadow: 0 0 0 5px rgba(139, 108, 219, 0.14);
transform: translateY(-1px);
}
.login-form__error {
padding: 13px 15px;
border: 1px solid rgba(225, 29, 72, 0.18);
border-radius: 18px;
background: rgba(255, 241, 242, 0.9);
color: #be123c !important;
font-size: 14px !important;
font-weight: 800;
}
.login-form__submit {
min-height: 58px;
border: 0;
border-radius: 999px;
color: #fff;
cursor: pointer;
font: 900 17px 'Fredoka', sans-serif;
background: linear-gradient(135deg, #7c52dc 0%, #ad42e8 48%, #f76cad 100%);
box-shadow: 0 18px 40px rgba(139, 108, 219, 0.32);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.login-form__submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 22px 48px rgba(139, 108, 219, 0.38);
}
.login-form__submit:disabled {
cursor: wait;
opacity: 0.72;
}
.login-card__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 24px;
color: #9286a7;
font-size: 14px;
font-weight: 700;
}
.login-card__footer a {
color: #7355c8;
text-decoration: none;
}
@keyframes loginTwinkle {
0%, 100% {
opacity: 0.45;
transform: scale(0.92) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1.08) rotate(8deg);
}
}
@media (max-width: 640px) {
.login-page {
place-items: start center;
padding-top: 104px;
}
.login-page__brand {
left: 18px;
top: 20px;
}
.login-card {
padding: 30px 22px;
border-radius: 30px;
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RefreshCw, ShieldCheck, Sparkles } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { loadSiteStatus } from '../lib/siteStatus'
import './notFoundView.css'
import './maintenanceView.css'
const route = useRoute()
const router = useRouter()
const refreshing = ref(false)
const title = ref('Sternenpause')
const message = ref('Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.')
const from = computed(() => {
const queryFrom = route.query.from
return typeof queryFrom === 'string' && queryFrom.startsWith('/') ? queryFrom : '/'
})
async function refreshCopy(force = false) {
const status = await loadSiteStatus(force)
title.value = status?.maintenanceTitle || 'Sternenpause'
message.value = status?.maintenanceMessage || 'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.'
return status
}
async function retry() {
refreshing.value = true
try {
const latest = await refreshCopy(true)
if (!latest?.maintenanceModeEnabled) {
await router.replace(from.value)
}
} finally {
refreshing.value = false
}
}
onMounted(() => {
void refreshCopy(true)
})
</script>
<template>
<div class="not-found maintenance-page">
<div class="not-found__sky"></div>
<div class="not-found__glow not-found__glow--violet"></div>
<div class="not-found__glow not-found__glow--peach"></div>
<div class="not-found__glow not-found__glow--pink"></div>
<span class="not-found__star not-found__star--1"></span>
<span class="not-found__star not-found__star--2"></span>
<span class="not-found__star not-found__star--3"></span>
<span class="not-found__star not-found__star--4"></span>
<span class="not-found__star not-found__star--5"></span>
<div class="not-found__content">
<div class="not-found__badge">
<Sparkles class="h-4 w-4" />
Wartungszauber aktiv
</div>
<div class="not-found__pillow-wrap">
<span class="maintenance-tea__spark maintenance-tea__spark--one"></span>
<span class="maintenance-tea__spark maintenance-tea__spark--two"></span>
<span class="maintenance-tea__spark maintenance-tea__spark--three"></span>
<div class="maintenance-tea" aria-hidden="true">
<div class="maintenance-tea__steam-field">
<span class="maintenance-tea__steam maintenance-tea__steam--one"></span>
<span class="maintenance-tea__steam maintenance-tea__steam--two"></span>
<span class="maintenance-tea__steam maintenance-tea__steam--three"></span>
<svg class="maintenance-tea__constellation" viewBox="0 0 220 112" role="presentation" aria-hidden="true">
<path d="M46 78 L82 42 L124 57 L168 28" />
<path d="M82 42 L104 88 L151 78" />
<circle cx="46" cy="78" r="5" />
<circle cx="82" cy="42" r="4.5" />
<circle cx="104" cy="88" r="4" />
<circle cx="124" cy="57" r="5.2" />
<circle cx="151" cy="78" r="4.2" />
<circle cx="168" cy="28" r="5.8" />
</svg>
</div>
<div class="maintenance-tea__cloud">
<span></span>
<span></span>
<span></span>
</div>
<div class="maintenance-tea__saucer"></div>
<div class="maintenance-tea__cup">
<span class="maintenance-tea__tea"></span>
<span class="maintenance-tea__shine"></span>
<span class="maintenance-tea__label">Pause</span>
</div>
<div class="maintenance-tea__handle"></div>
</div>
</div>
<p class="not-found__eyebrow">Die Sterne werden neu sortiert</p>
<h1 class="not-found__title">{{ title }}</h1>
<p class="not-found__text">{{ message }}</p>
<div class="not-found__actions">
<button class="not-found__primary" :disabled="refreshing" @click="retry">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': refreshing }" />
{{ refreshing ? 'Prüft die Galaxie ...' : 'Erneut versuchen' }}
</button>
<button class="not-found__secondary" @click="router.push('/login?redirect=/admin/settings')">
<ShieldCheck class="h-4 w-4" />
Admin öffnen
</button>
</div>
</div>
</div>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { RefreshCw, WifiOff } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import { useAwardsStore } from '../stores/awards'
import './networkErrorView.css'
const route = useRoute()
const router = useRouter()
const store = useAwardsStore()
const retrying = ref(false)
const online = ref(typeof navigator === 'undefined' ? true : navigator.onLine)
const from = computed(() => {
const queryFrom = route.query.from
return typeof queryFrom === 'string' && queryFrom.startsWith('/') ? queryFrom : '/'
})
const isServerError = computed(() => store.lastPublicErrorKind === 'server')
const title = computed(() =>
isServerError.value
? 'Die Bühne hat gerade einen kleinen 500-Meltdown'
: online.value
? 'Der Sternenhimmel sucht noch die Verbindung'
: 'Du bist gerade offline durch die Galaxie unterwegs',
)
const text = computed(() =>
isServerError.value
? 'Der Server hat mit einem internen Fehler geantwortet. Statt einer kaputten Seite bekommst du hier die cineastische Fallback-Ansicht, bis die Bühne wieder steht.'
: online.value
? 'Die Award-Seite antwortet im Moment nicht. Das wirkt wie ein Server- oder Netzwerkproblem zwischen Browser und Backend.'
: 'Ohne Internet können Landingpage, Voting und Archiv nicht sauber geladen werden. Sobald die Verbindung zurück ist, kannst du direkt neu ansetzen.',
)
const detail = computed(() => store.lastPublicError || 'Bitte prüfe deine Verbindung und versuche es gleich noch einmal.')
function syncOnlineState() {
online.value = typeof navigator === 'undefined' ? true : navigator.onLine
}
async function retry() {
retrying.value = true
syncOnlineState()
try {
await store.loadHomeData()
if (store.apiMode === 'api') {
await router.replace(from.value)
}
} finally {
retrying.value = false
}
}
onMounted(() => {
window.addEventListener('online', syncOnlineState)
window.addEventListener('offline', syncOnlineState)
})
onBeforeUnmount(() => {
window.removeEventListener('online', syncOnlineState)
window.removeEventListener('offline', syncOnlineState)
})
</script>
<template>
<div class="network-scene">
<div class="network-scene__nebula network-scene__nebula--violet"></div>
<div class="network-scene__nebula network-scene__nebula--rose"></div>
<div class="network-scene__nebula network-scene__nebula--gold"></div>
<div class="network-scene__stars network-scene__stars--far"></div>
<div class="network-scene__stars network-scene__stars--near"></div>
<div class="network-scene__orbital network-scene__orbital--one"></div>
<div class="network-scene__orbital network-scene__orbital--two"></div>
<div class="network-scene__comet network-scene__comet--one"></div>
<div class="network-scene__comet network-scene__comet--two"></div>
<div class="network-scene__content">
<div class="network-scene__signal">
<div class="network-scene__signal-ring network-scene__signal-ring--outer"></div>
<div class="network-scene__signal-ring network-scene__signal-ring--mid"></div>
<div class="network-scene__signal-core">
<WifiOff class="h-10 w-10" />
</div>
</div>
<div class="network-scene__copy">
<p class="network-scene__eyebrow">{{ isServerError ? 'Serverfehler 500' : 'Verbindungsproblem' }}</p>
<h1 class="network-scene__title">{{ title }}</h1>
<p class="network-scene__text">{{ text }}</p>
<p class="network-scene__detail">{{ detail }}</p>
</div>
<div class="network-scene__actions">
<Button :disabled="retrying" @click="retry">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': retrying }" />
{{ retrying ? 'Versucht erneut ...' : 'Erneut versuchen' }}
</Button>
<button class="network-scene__ghost" @click="router.replace('/')">Zur Landingpage</button>
</div>
</div>
</div>
</template>
-209
View File
@@ -1,209 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { Plus, Sparkles, Star, Trophy, X } from '@lucide/vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const nomineeName = ref('')
const nominees = ref<string[]>(['Hoshimi Miyu', 'Kurainu'])
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
selectedCategoryId.value = store.categories.categories[0]?.id ?? null
})
const categories = computed(() =>
store.categories.categories.map((category) => ({
label: category.name,
value: category.id,
})),
)
const selectedCategory = computed(() =>
store.categories.categories.find((category) => category.id === selectedCategoryId.value),
)
const slotsLeft = computed(() => 3 - nominees.value.length)
function slugFor(name: string) {
return `@${name.toLowerCase().replace(/\s+/g, '')}`
}
function addNominee() {
const value = nomineeName.value.trim()
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
nominees.value = [...nominees.value, value]
nomineeName.value = ''
}
function removeNominee(name: string) {
nominees.value = nominees.value.filter((entry) => entry !== name)
}
async function submitNomination() {
if (!selectedCategoryId.value || nominees.value.length === 0) return
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
const response = await store.submitNomination({
year: store.categories.year,
categoryId: selectedCategoryId.value,
twitchUserId: authStore.session?.twitchUserId ?? '',
nominees: nominees.value,
})
submitMessage.value = `Stark! ${response.saved} Nominierung(en) für ${response.category} sind eingetragen.`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Schritt 1 · Nominieren"
title="Wen feiern wir dieses Jahr?"
:description="`Trag deine Lieblings-Creator ein und bring sie auf die große Bühne. Pro Kategorie hast du ${ 3 } Plätze nutz sie für die VTuber, die dein Jahr gemacht haben.`"
:icon="Star"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Kategorie</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Favoriten eintragen</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Abschicken</span>
</div>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<!-- Left: category + add -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Nominierung. 💜
</p>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">In welcher Kategorie?</label>
<Select
v-model="selectedCategoryId"
:options="categories"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div v-if="selectedCategory" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ selectedCategory.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-600">{{ selectedCategory.description }}</p>
</div>
<div class="space-y-3 rounded-2xl border border-violet-100 bg-white/70 p-5">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Wen möchtest du nominieren?</label>
<input
v-model="nomineeName"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm"
placeholder="Name oder @handle, z. B. Shiro Ch."
@keyup.enter="addNominee"
/>
<Button class="w-full gap-2" :disabled="slotsLeft <= 0" @click="addNominee">
<Plus class="h-4 w-4" /> {{ slotsLeft > 0 ? 'Zur Liste hinzufügen' : 'Alle Plätze belegt' }}
</Button>
</div>
</div>
<!-- Right: draft -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Deine Favoriten</h3>
<p class="text-sm text-slate-500">
{{ slotsLeft > 0 ? `Noch ${slotsLeft} Platz${slotsLeft === 1 ? '' : 'e'} frei` : 'Volle Liste richtig so!' }}
</p>
</div>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">{{ nominees.length }}/3</span>
</div>
<transition-group name="list" tag="div" class="space-y-2">
<div
v-for="(name, index) in nominees"
:key="name"
class="flex items-center justify-between rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
>
<div class="flex items-center gap-3">
<span class="grid h-10 w-10 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ name.charAt(0) }}
</span>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-xs text-slate-500">{{ slugFor(name) }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-violet-400">#{{ index + 1 }}</span>
<button class="grid h-8 w-8 place-items-center rounded-full text-rose-400 transition hover:bg-rose-50" @click="removeNominee(name)">
<X class="h-4 w-4" />
</button>
</div>
</div>
</transition-group>
<div v-if="nominees.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-5 py-8 text-center">
<Sparkles class="mx-auto h-6 w-6 text-amber-400" />
<p class="mt-2 text-sm text-slate-500">Noch leer wer hat dieses Jahr deinen Bildschirm zum Leuchten gebracht?</p>
</div>
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Bis zu 3 Favoriten pro Kategorie keine Person doppelt.</p>
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Alles bleibt bis zum Ende der Phase änderbar.</p>
</div>
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
<Button
class="w-full gap-2"
:disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0"
@click="submitNomination"
>
<Trophy class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Nominierungen abschicken' }}
</Button>
</div>
</div>
</Card>
</div>
</template>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.25s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ArrowLeft, Home, Stars } from '@lucide/vue'
import { useRouter } from 'vue-router'
import './notFoundView.css'
const router = useRouter()
</script>
<template>
<div class="not-found">
<div class="not-found__sky"></div>
<div class="not-found__glow not-found__glow--violet"></div>
<div class="not-found__glow not-found__glow--peach"></div>
<div class="not-found__glow not-found__glow--pink"></div>
<span class="not-found__star not-found__star--1"></span>
<span class="not-found__star not-found__star--2"></span>
<span class="not-found__star not-found__star--3"></span>
<span class="not-found__star not-found__star--4"></span>
<span class="not-found__star not-found__star--5"></span>
<div class="not-found__content">
<div class="not-found__badge">
<Stars class="h-4 w-4" />
Sternenroute verloren
</div>
<div class="not-found__pillow-wrap">
<span class="not-found__pillow-spark not-found__pillow-spark--one"></span>
<span class="not-found__pillow-spark not-found__pillow-spark--two"></span>
<span class="not-found__pillow-spark not-found__pillow-spark--three"></span>
<div class="not-found__pillow-shadow"></div>
<div class="not-found__star-pillow">
<span class="not-found__star-shine"></span>
<span class="not-found__star-code">404</span>
</div>
</div>
<p class="not-found__eyebrow">Oopsie im Sternenhimmel</p>
<h1 class="not-found__title">Diese Seite wurde nicht gefunden</h1>
<p class="not-found__text">
Die Route ist wohl in einem Pastell-Nebel verschwunden. Lass uns dich zurück zur Award-Galaxie bringen.
</p>
<div class="not-found__actions">
<button class="not-found__primary" @click="router.push('/')">
<Home class="h-4 w-4" />
Zur Startseite
</button>
<button class="not-found__secondary" @click="router.back()">
<ArrowLeft class="h-4 w-4" />
Zurück
</button>
</div>
</div>
</div>
</template>
-189
View File
@@ -1,189 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { Check, PlayCircle, Sparkles, Star, Vote } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const selectedCandidateId = ref<number | null>(null)
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
selectedCategoryId.value = store.categories.categories[0]?.id ?? null
})
const categoryOptions = computed(() =>
store.categories.categories.map((category) => ({
label: category.name,
value: category.id,
})),
)
const category = computed(() =>
store.categories.categories.find((item) => item.id === selectedCategoryId.value) ?? store.categories.categories[0],
)
const selectedCandidate = computed(() =>
category.value?.candidates.find((candidate) => candidate.id === selectedCandidateId.value),
)
async function submitVote() {
if (!category.value || !selectedCandidateId.value) return
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
const response = await store.submitVote({
seasonId: store.categories.seasonId,
twitchUserId: authStore.session?.twitchUserId ?? '',
entries: [
{
categoryId: category.value.id,
candidateId: selectedCandidateId.value,
},
],
})
submitMessage.value = `Stimme gezählt! (Ballot #${response.ballotId}) Danke fürs Mitmachen. 💜`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Schritt 2 · Voten"
title="Jetzt zählt deine Stimme"
description="Eine Stimme pro Kategorie wähl mit Herz. Deine Wahl bleibt bis zum Ende der Voting-Phase jederzeit änderbar, also kein Stress."
:icon="Vote"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Kategorie</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Favorit wählen</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Stimme abgeben</span>
</div>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<!-- Left -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Stimme. 💜
</p>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Für welche Kategorie stimmst du?</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div v-if="category" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ category.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ category.name }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-600">{{ category.description }}</p>
</div>
<p class="flex items-start gap-2 rounded-2xl border border-violet-100 bg-white/70 px-5 py-4 text-sm text-slate-600">
<Star class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
Nur eine Stimme zählt pro Kategorie aber du darfst sie jederzeit umentscheiden.
</p>
<p class="flex items-start gap-2 rounded-2xl border border-violet-100 bg-white/70 px-5 py-4 text-sm text-slate-600">
<PlayCircle class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" />
Schau dir die eingereichten Clips an, bevor du wählst so siehst du, was jede:n in dieser Kategorie besonders macht.
</p>
</div>
<!-- Right: candidate grid -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Die Nominierten</h3>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">
{{ category?.candidates.length ?? 0 }} im Rennen
</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label
v-for="candidate in category?.candidates ?? []"
:key="candidate.id"
:class="[
'group flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selectedCandidateId === candidate.id
? 'border-violet-400 bg-violet-50 ring-2 ring-violet-200'
: 'border-violet-100 bg-white hover:border-violet-300 hover:bg-violet-50/40',
]"
>
<input v-model="selectedCandidateId" type="radio" :value="candidate.id" class="sr-only" />
<span class="grid h-11 w-11 shrink-0 place-items-center self-start rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0 flex-1">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
<a
v-if="candidate.clipUrl"
:href="candidate.clipUrl"
target="_blank"
rel="noopener"
class="mt-1.5 inline-flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800"
@click.stop
>
<PlayCircle class="h-3.5 w-3.5" /> Clip ansehen
</a>
</div>
<span
:class="[
'grid h-6 w-6 shrink-0 place-items-center self-start rounded-full border transition',
selectedCandidateId === candidate.id ? 'border-violet-500 bg-violet-500 text-white' : 'border-violet-200 text-transparent',
]"
>
<Check class="h-3.5 w-3.5" />
</span>
</label>
</div>
<div class="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-[linear-gradient(135deg,#f6ecff,#fff2dd)] px-6 py-5">
<div class="space-y-1">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-400">Deine Wahl</p>
<p class="font-[Cormorant_Garamond] text-2xl text-violet-800">
{{ selectedCandidate?.displayName ?? 'Noch nichts gewählt' }}
</p>
<p v-if="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
</div>
<Button class="gap-2" :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
<Sparkles class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Stimme abgeben' }}
</Button>
</div>
</div>
</div>
</Card>
</div>
</template>
-84
View File
@@ -1,84 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Crown, Star, Trophy } from '@lucide/vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
const store = useAwardsStore()
const years = [2025, 2024, 2023, 2022]
const activeYear = ref(2025)
const winnerGradients = [
'from-violet-200 to-fuchsia-100',
'from-indigo-200 to-violet-100',
'from-sky-200 to-violet-100',
'from-rose-200 to-amber-100',
'from-violet-200 to-amber-100',
'from-fuchsia-200 to-rose-100',
]
onMounted(async () => {
await store.loadHomeData()
await store.loadArchive(activeYear.value)
})
async function selectYear(year: number) {
activeYear.value = year
await store.loadArchive(year)
}
</script>
<template>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Gewinner Archiv"
title="Jahre, Gewinner und Show-Historie"
description="Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien, Gewinner und Banner bleiben pro Jahr nachvollziehbar."
:icon="Trophy"
/>
<!-- Year tabs -->
<div class="flex flex-wrap gap-2">
<button
v-for="year in years"
:key="year"
:class="[
'rounded-full px-5 py-2 text-sm font-semibold transition',
activeYear === year
? 'bg-violet-600 text-white shadow-lg shadow-violet-500/20'
: 'border border-violet-200 bg-white text-violet-600 hover:bg-violet-50',
]"
@click="selectYear(year)"
>
{{ year }}
</button>
</div>
<!-- Winner grid -->
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Card
v-for="(item, index) in store.archive.items"
:key="`${store.archive.year}-${item.category}`"
class="overflow-hidden p-0"
>
<div :class="['relative h-[220px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<div class="absolute inset-0 grid place-items-center text-white/70">
<Star class="h-12 w-12" />
</div>
<span class="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-[linear-gradient(135deg,#ffd97a,#f6b938)] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-950">
<Crown class="h-3 w-3" /> Winner {{ store.archive.year }}
</span>
</div>
<div class="p-5">
<p class="text-[10px] uppercase tracking-[0.2em] text-violet-400">{{ item.category }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ item.winnerName }}</p>
<p class="text-sm text-slate-500">{{ item.winnerSlug }}</p>
</div>
</Card>
</div>
<p class="text-xs text-slate-400">Bilder sind Platzhalter echte Banner werden pro Jahr im Admin gepflegt.</p>
</div>
</template>
+125 -90
View File
@@ -1,65 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, Clock3, Sparkles, Tags, Users, Vote } from '@lucide/vue'
import { BarChart3, CheckCircle2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
const maxVotes = computed(() => Math.max(...store.admin.topCategories.map((category) => category.votes), 1))
const categoryHealth = computed(() =>
seasonDetail.value.categories
.map((category) => ({
name: category.name,
groupName: category.groupName,
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
reviews: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
}))
.sort((a, b) => b.candidates - a.candidates || b.reviews - a.reviews),
)
const metricCards = computed(() => [
{ label: 'Nominierungen', value: totalNominations.value, note: 'gesamt im Jahr', icon: Sparkles },
{ label: 'Stimmen', value: totalVotes.value, note: 'alle Votes', icon: Vote },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'in allen Kategorien', icon: Users },
{ label: 'Reviews offen', value: seasonDetail.value.pendingNominations.length, note: 'Backlog', icon: Clock3 },
])
const insights = computed(() => {
const categoriesWithoutCandidates = categoryHealth.value.filter((category) => category.candidates === 0).length
const busiestReviewCategory = [...categoryHealth.value].sort((a, b) => b.reviews - a.reviews)[0]
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
return [
{
label: 'Votes pro Kandidat',
value: votesPerCandidate,
note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.',
},
{
label: 'Leere Kategorien',
value: categoriesWithoutCandidates,
note: categoriesWithoutCandidates === 0 ? 'Alle Kategorien sind besetzt.' : 'Diese Kategorien brauchen Kandidatenpflege.',
},
{
label: 'Review-Hotspot',
value: busiestReviewCategory?.reviews ?? 0,
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine Review-Daten vorhanden.',
},
]
})
const {
categoryHealth,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
maxVotes,
winnerCoveragePct,
} = useAdminAnalyticsManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Analytics"
title="Zahlen, die Entscheidungen helfen"
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Lücken oder Backlog entstehen."
description="Jahresmetriken, Readiness und Kategoriegesundheit auf einen Blick."
:icon="BarChart3"
/>
@@ -69,63 +33,134 @@ const insights = computed(() => {
<Card v-for="metric in metricCards" :key="metric.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ metric.label }}</p>
<strong class="mt-2 block text-3xl text-slate-950">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-1 text-sm text-slate-500">{{ metric.note }}</p>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl border" :class="metric.tone">
<component :is="metric.icon" class="h-5 w-5" />
</div>
</span>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<section class="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Vote Performance</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top-Kategorien</h2>
<div class="mt-6 space-y-4">
<div v-for="category in store.admin.topCategories" :key="category.category" class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<div class="flex items-center justify-between gap-4">
<p class="font-semibold text-slate-900">{{ category.category }}</p>
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-[#f3ecff]">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6,#f8d7a4)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
</div>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Finale Vollständigkeit</h2>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm font-bold text-violet-800">
{{ winnerCoveragePct }}% Gewinner
</div>
</div>
<div class="mt-5 grid gap-3">
<RouterLink
v-for="item in readinessCards"
:key="item.label"
:to="item.to"
class="flex items-start gap-4 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-5 w-5" />
</span>
<span class="min-w-0">
<span class="block text-sm font-semibold text-slate-500">{{ item.label }}</span>
<strong class="mt-1 block text-2xl leading-tight text-slate-950">{{ item.value }}</strong>
<span class="mt-1 block text-sm leading-5 text-slate-500">{{ item.note }}</span>
</span>
</RouterLink>
</div>
</Card>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie Health</p>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Abdeckung</h2>
</div>
<Tags class="h-6 w-6 text-violet-500" />
</div>
<Card class="p-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aufmerksamkeit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was Admins zuerst prüfen sollten</h2>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="category in categoryHealth" :key="category.name" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.reviews }} offene Reviews</p>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">
{{ category.candidates }} Kandidaten
</span>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<RouterLink
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
<div class="mt-6 grid gap-3 md:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
</div>
</div>
</Card>
</section>
<section class="grid gap-4 lg:grid-cols-3">
<Card v-for="insight in insights" :key="insight.label" class="p-5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ insight.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ insight.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ insight.note }}</p>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kandidaten, Reviews und Gewinnerstatus</h2>
</div>
<div class="max-h-[520px] overflow-y-auto p-4">
<div class="grid gap-3">
<RouterLink
v-for="category in categoryHealth"
:key="category.id"
to="/admin/categories"
class="grid gap-3 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60 md:grid-cols-[minmax(0,1fr)_110px_110px_130px]"
>
<span class="min-w-0">
<strong class="block truncate text-slate-950">{{ category.name }}</strong>
<span class="mt-1 block text-sm text-slate-500">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
Stimmen
</span>
<span class="inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
{{ category.status }}
</span>
</RouterLink>
</div>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Top Kategorien</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Stimmenverteilung</h2>
<div class="mt-5 space-y-4">
<div v-for="category in topCategories.slice(0, 8)" :key="category.category">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-semibold text-slate-800">{{ category.category }}</span>
<span class="shrink-0 text-slate-500">{{ category.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-violet-100">
<div class="h-full rounded-full bg-violet-600" :style="{ width: `${Math.max(4, Math.round((category.votes / maxVotes) * 100))}%` }"></div>
</div>
</div>
<div v-if="topCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 p-5 text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</div>
<div class="mt-6 flex items-center gap-2 rounded-[22px] border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">
<CheckCircle2 class="h-5 w-5 shrink-0" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
</div>
</Card>
</section>
</div>
+86 -308
View File
@@ -1,168 +1,54 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select'
import { ChevronLeft, ChevronRight, Layers3, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue'
import { Layers3, UserPlus, Users } from '@lucide/vue'
import AdminCandidateDeleteModal from '../../components/admin/AdminCandidateDeleteModal.vue'
import AdminCandidateEditorModal from '../../components/admin/AdminCandidateEditorModal.vue'
import AdminCandidatesFiltersBar from '../../components/admin/AdminCandidatesFiltersBar.vue'
import AdminCandidatesTable from '../../components/admin/AdminCandidatesTable.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import { useAdminCandidateManager } from '../../components/admin/useAdminCandidateManager'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
const store = useAwardsStore()
const saving = ref(false)
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
/* ---------- Filter + Suche + Pagination ---------- */
const search = ref('')
const categoryFilter = ref<number | null>(null)
const page = ref(1)
const pageSize = 10
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
)
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])),
)
const duplicateCandidateKeys = computed(() => {
const counts = new Map<string, number>()
for (const candidate of seasonDetail.value.candidates) {
const categoryKey = `${candidate.categoryId}`
const nameKey = `${categoryKey}:name:${candidate.displayName.trim().toLowerCase()}`
const slugKey = `${categoryKey}:slug:${candidate.channelSlug.trim().toLowerCase()}`
counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1)
if (candidate.channelSlug.trim()) counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1)
}
return counts
})
const duplicateCandidateCount = computed(() =>
seasonDetail.value.candidates.filter((candidate) =>
(duplicateCandidateKeys.value.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1 ||
(duplicateCandidateKeys.value.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1,
).length,
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
if (query) {
list = list.filter((c) =>
[c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
.join(' ')
.toLowerCase()
.includes(query),
)
}
return list
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
const pagedCandidates = computed(() => {
const start = (page.value - 1) * pageSize
return filteredCandidates.value.slice(start, start + pageSize)
})
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
function clearFilters() {
search.value = ''
categoryFilter.value = null
}
/* ---------- Modal: anlegen / bearbeiten ---------- */
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const form = reactive({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim()),
)
function openCreate() {
adminMessage.value = ''
adminError.value = ''
editingId.value = 'new'
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
modalOpen.value = true
}
function openEdit(candidate: AdminCandidateItem) {
adminMessage.value = ''
adminError.value = ''
editingId.value = candidate.id
form.categoryId = candidate.categoryId
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
modalOpen.value = true
}
async function saveModal() {
if (!canSave.value || !selectedSeasonId.value) return
saving.value = true
adminError.value = ''
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
/* ---------- Löschen mit Bestätigung ---------- */
const candidateToDelete = ref<AdminCandidateItem | null>(null)
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `${candidateToDelete.value.displayName}" wurde gelöscht.`
candidateToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
seasonDetail,
saving,
deleting,
adminMessage,
adminError,
search,
categoryFilter,
page,
categoryOptions,
categoryFilterOptions,
categoryLabelMap,
duplicateCandidateKeys,
duplicateCandidateCount,
filteredCandidates,
pagedCandidates,
totalPages,
rangeStart,
rangeEnd,
modalOpen,
form,
modalTitle,
canSave,
candidatePlatformOptions,
selectedPlatformValue,
candidateToDelete,
clearFilters,
openCreate,
openEdit,
handlePlatformSelection,
saveModal,
confirmDelete,
} = useAdminCandidateManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kandidaten"
title="Kandidaten verwalten"
description="Suchen, filtern, anlegen, bearbeiten und löschen auch bei vielen Nominierten bleibt die Liste übersichtlich."
:icon="Users"
/>
@@ -199,167 +85,59 @@ async function confirmDelete() {
</section>
<Card class="overflow-hidden">
<!-- Toolbar -->
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
<label class="relative block flex-1">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="search"
type="text"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name, Handle oder Plattform suchen …"
/>
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full lg:w-72"
/>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="openCreate">
<UserPlus class="h-4 w-4" /> Kandidat anlegen
</Button>
</div>
<AdminCandidatesFiltersBar
:search="search"
:category-filter="categoryFilter"
:category-filter-options="categoryFilterOptions"
@update:search="search = $event"
@update:category-filter="categoryFilter = $event"
@clear-filters="clearFilters"
@open-create="openCreate"
/>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<!-- Tabellenkopf (Desktop) -->
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
<!-- Zeilen -->
<div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
>
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
<p
v-if="(duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1 || (duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1"
class="mt-1 text-xs font-semibold text-amber-700"
>
Mögliches Duplikat in dieser Kategorie
</p>
</div>
</div>
<div class="min-w-0">
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<div>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
<div class="flex gap-2 lg:justify-end">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
title="Bearbeiten"
@click="openEdit(candidate)"
>
<Pencil class="h-4 w-4" />
</button>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Löschen"
@click="candidateToDelete = candidate"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
<p class="text-sm text-slate-500">
{{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
<Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
</div>
</div>
<!-- Pagination -->
<div v-if="filteredCandidates.length > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
<div class="flex items-center gap-2">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page <= 1"
@click="page--"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="page++"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
<AdminCandidatesTable
:paged-candidates="pagedCandidates"
:total-count="seasonDetail.candidates.length"
:filtered-count="filteredCandidates.length"
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:category-label-map="categoryLabelMap"
:duplicate-candidate-keys="duplicateCandidateKeys"
@edit="openEdit"
@delete="candidateToDelete = $event"
@update:page="page = $event"
@open-create="openCreate"
/>
</Card>
<!-- Modal: anlegen / bearbeiten -->
<Modal :open="modalOpen" :title="modalTitle" subtitle="Anzeigename und Handle sind Pflicht." @close="modalOpen = false">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube …" />
</label>
</div>
</div>
<template #footer>
<Button variant="ghost" @click="modalOpen = false">Abbrechen</Button>
<Button :disabled="saving || !canSave" @click="saveModal">{{ saving ? 'Speichert ' : 'Speichern' }}</Button>
</template>
</Modal>
<AdminCandidateEditorModal
:open="modalOpen"
:title="modalTitle"
:form="form"
:category-options="categoryOptions"
:candidate-platform-options="candidatePlatformOptions"
:selected-platform-value="selectedPlatformValue"
:can-save="canSave"
:saving="saving"
@close="modalOpen = false"
@save="saveModal"
@update:category-id="form.categoryId = $event"
@update:display-name="form.displayName = $event"
@update:channel-slug="form.channelSlug = $event"
@update:platform="form.platform = $event"
@platform-selection="handlePlatformSelection"
/>
<!-- Modal: löschen bestätigen -->
<Modal :open="!!candidateToDelete" title="Kandidat löschen?" @close="candidateToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ candidateToDelete?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="candidateToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
<AdminCandidateDeleteModal
:candidate="candidateToDelete"
:deleting="deleting"
@close="candidateToDelete = null"
@confirm="confirmDelete"
/>
</div>
</template>
+24 -167
View File
@@ -1,183 +1,40 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCategoryItem } from '../../types/awards'
const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const seasonDetail = computed(() => store.adminSeasonDetail)
const query = ref('')
const statusFilter = ref<'all' | 'empty' | 'reviews' | 'thin'>('all')
const selectedCategoryId = ref<number | null>(null)
const saving = ref<number | 'new' | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const categoriesWithState = computed(() =>
seasonDetail.value.categories
.map((category) => ({
...category,
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
)
const filteredCategories = computed(() => {
const search = query.value.trim().toLowerCase()
return categoriesWithState.value.filter((category) => {
const matchesStatus =
statusFilter.value === 'all' ||
(statusFilter.value === 'empty' && category.candidates === 0) ||
(statusFilter.value === 'reviews' && category.pending > 0) ||
(statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser))
const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description]
.join(' ')
.toLowerCase()
.includes(search)
return matchesStatus && matchesSearch
})
})
const selectedCategory = computed(() =>
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
)
const categoryStats = computed(() => [
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
{ key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
])
watch(
seasonDetail,
(detail) => {
for (const category of detail.categories) {
editForms[category.id] = {
groupName: category.groupName,
name: category.name,
slug: category.slug,
description: category.description,
sortOrder: category.sortOrder,
maxNomineesPerUser: category.maxNomineesPerUser,
}
}
newCategoryForm.sortOrder = detail.categories.length + 1
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = detail.categories[0]?.id ?? null
}
},
{ immediate: true },
)
watch(filteredCategories, (categories) => {
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
selectedCategoryId.value = categories[0]?.id ?? null
}
})
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
saving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
saving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
saving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Kategorie angelegt.'
newCategoryForm.groupName = ''
newCategoryForm.name = ''
newCategoryForm.slug = ''
newCategoryForm.description = ''
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
newCategoryForm.maxNomineesPerUser = 3
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
saving.value = null
}
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function fillNewSlug() {
newCategoryForm.slug = slugify(newCategoryForm.name)
}
const categoryToDelete = ref<AdminCategoryItem | null>(null)
const deleting = ref(false)
async function confirmDeleteCategory() {
if (!categoryToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
categoryToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
selectedSeasonId,
query,
statusFilter,
selectedCategoryId,
saving,
adminMessage,
adminError,
editForms,
newCategoryForm,
filteredCategories,
selectedCategory,
categoryStats,
statusFilters,
categoryToDelete,
deleting,
saveCategory,
createCategory,
fillNewSlug,
confirmDeleteCategory,
} = useAdminCategoryManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kategorien"
title="Award-Struktur pflegen"
description="Eine kompakte Arbeitsansicht für viele Kategorien: links filtern und auswählen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
:icon="Tags"
/>
@@ -243,7 +100,7 @@ async function confirmDeleteCategory() {
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorie bearbeiten</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ selectedCategory.description }}</p>
</div>
<div class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-100 text-violet-700">
@@ -301,7 +158,7 @@ async function confirmDeleteCategory() {
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
<h2 class="mt-1 font-[Cormorant_Garamond] text-3xl text-violet-800">Kategorie anlegen</h2>
<h2 class="mt-1 text-lg font-bold text-slate-900">Kategorie anlegen</h2>
</div>
</div>
+68 -105
View File
@@ -1,117 +1,51 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ExternalLink, Film, Layers3, PlayCircle, Search, Trash2, TriangleAlert, Users } from '@lucide/vue'
import { CheckCircle2, ExternalLink, Film, Search, Trash2, TriangleAlert, Undo2, Users, XCircle } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminClipManager } from '../../components/admin/useAdminClipManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore()
const query = ref('')
const statusFilter = ref<'all' | 'pending' | 'reviewed'>('all')
const platformFilter = ref<'all' | string>('all')
const categoryFilter = ref('all')
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
)
const clips = computed(() => {
const search = query.value.trim().toLowerCase()
return submissions.value.filter((clip) =>
(statusFilter.value === 'all' || clip.status === statusFilter.value || (statusFilter.value === 'reviewed' && clip.status !== 'pending')) &&
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const duplicateUrls = computed(() => {
const counts = new Map<string, number>()
for (const clip of submissions.value) {
const key = clip.clipUrl.trim().toLowerCase()
if (!key) continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
})
const stats = computed(() => [
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
])
const platformFilters = computed(() => [
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
.sort()
.map((platform) => ({
key: platform,
label: platform,
count: submissions.value.filter((clip) => clip.platform === platform).length,
})),
])
const categoryFilters = computed(() => [
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
...categories.value
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
.map((category) => ({
id: String(category.id),
label: category.name,
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
})),
])
function platformClass(platform: string) {
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
async function confirmDelete() {
if (!clipToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
clipToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
query,
statusFilter,
platformFilter,
categoryFilter,
deleting,
statusSaving,
adminMessage,
adminError,
clipToDelete,
reviewNotes,
submissions,
categoryName,
clips,
stats,
statusFilters,
platformFilters,
categoryFilters,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
confirmDelete,
} = useAdminClipManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
title="Clip-Einreichungen triagieren"
description="Clips sind eine eigene Award-Arbeitsfläche: nach Kategorie, Plattform und Status filtern, Duplikate erkennen, Links prüfen und Spam oder falsche Einreichungen entfernen."
:icon="Film"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 lg:grid-cols-3">
<section class="grid gap-4 lg:grid-cols-4">
<Card v-for="stat in stats" :key="stat.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
@@ -172,27 +106,56 @@ async function confirmDelete() {
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }}
</p>
<p v-if="duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) && duplicateUrls.get(clip.clipUrl.trim().toLowerCase())! > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) }}x eingereicht.
<p v-if="duplicateUrlCount(clip.clipUrl) > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrlCount(clip.clipUrl) }}x eingereicht.
</p>
<p v-if="creatorClipCount(clip) > 1" class="mt-1 text-xs font-semibold text-violet-700">
Sammelpunkt: {{ creatorClipCount(clip) }} Clips für diese Person oder diesen Kandidaten.
</p>
<p v-if="clip.reviewedAt" class="mt-1 text-xs text-slate-500">
Zuletzt geprüft von {{ clip.reviewedByTwitchId || 'Admin' }} · {{ new Date(clip.reviewedAt).toLocaleString('de-DE') }}
</p>
</div>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<span class="shrink-0 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">{{ clip.status }}</span>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', statusClass(clip.status)]">{{ statusLabel(clip.status) }}</span>
<a
:href="clip.clipUrl"
target="_blank"
rel="noopener"
referrerpolicy="no-referrer"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
>
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
</a>
<button
class="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
<div class="w-full lg:w-[320px]">
<textarea
v-model="reviewNotes[clip.id]"
rows="2"
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="Moderationsnotiz für Team oder spätere Rückfragen"
/>
<div class="mt-3 flex flex-wrap gap-2">
<Button variant="secondary" class="gap-1.5" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'pending')">
<Undo2 class="h-4 w-4" />
Zurück auf offen
</Button>
<Button class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'approved')">
<CheckCircle2 class="h-4 w-4" />
Freigeben
</Button>
<Button class="gap-1.5 !bg-rose-600 hover:!bg-rose-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'rejected')">
<XCircle class="h-4 w-4" />
Ablehnen
</Button>
<button
class="grid h-10 w-10 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { FileText } from '@lucide/vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
import AdminContentSectionNav from '../../components/admin/AdminContentSectionNav.vue'
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
const sectionLinks = [
{ href: '#content-basics', label: 'Basis & Host', primary: true },
{ href: '#content-links', label: 'Footer & Kontakt' },
{ href: '#content-socials', label: 'Social Links' },
{ href: '#content-faq', label: 'FAQ' },
{ href: '#content-privacy', label: 'Datenschutz' },
]
const {
form,
saving,
saveMessage,
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
addFaqItem,
removeFaqItem,
isUploadedIcon,
selectedSocialIconValue,
handleSocialIconSelection,
hasSocialIconPreview,
socialIconModeLabel,
socialSimpleIconPath,
socialSimpleIconColor,
handleSocialIconUpload,
clearSocialIcon,
saveSiteSettings,
adminSiteSettings,
} = useAdminContentManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Content Hub"
description="Landingpage, Footer, Social Links und Rechtstexte pflegen."
:icon="FileText"
/>
<div class="space-y-3">
<p v-if="saveMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ saveMessage }}</p>
<p v-if="saveError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ saveError }}</p>
</div>
<section class="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)]">
<aside class="space-y-4 xl:sticky xl:top-32 xl:self-start">
<AdminContentSectionNav :sections="sectionLinks" />
</aside>
<div class="space-y-6">
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentLinksSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentSocialLinksSection
:form="form"
:saving="saving"
:icon-upload-error="iconUploadError"
:add-social-link="addSocialLink"
:remove-social-link="removeSocialLink"
:is-uploaded-icon="isUploadedIcon"
:selected-social-icon-value="selectedSocialIconValue"
:handle-social-icon-selection="handleSocialIconSelection"
:has-social-icon-preview="hasSocialIconPreview"
:social-icon-mode-label="socialIconModeLabel"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:handle-social-icon-upload="handleSocialIconUpload"
:clear-social-icon="clearSocialIcon"
:save-site-settings="saveSiteSettings"
/>
<AdminContentFaqSection
:form="form"
:saving="saving"
:add-faq-item="addFaqItem"
:remove-faq-item="removeFaqItem"
:save-site-settings="saveSiteSettings"
/>
<AdminContentPrivacySection
:form="form"
:saving="saving"
:updated-by="adminSiteSettings.privacyPolicyUpdatedBy"
:updated-label="privacyUpdatedLabel"
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
</div>
</section>
<AdminContentPrivacyPreviewModal
:open="privacyPreviewOpen"
:blocks="privacyPreviewBlocks"
:updated-label="privacyUpdatedLabel"
@close="privacyPreviewOpen = false"
/>
</div>
</template>
+42 -355
View File
@@ -1,382 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowDownRight, ArrowUpRight, BarChart3, Clock3, LayoutDashboard, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { LayoutDashboard } from '@lucide/vue'
import AdminDashboardActivitySection from '../../components/admin/AdminDashboardActivitySection.vue'
import AdminDashboardChecksSection from '../../components/admin/AdminDashboardChecksSection.vue'
import AdminDashboardHeroSection from '../../components/admin/AdminDashboardHeroSection.vue'
import AdminDashboardPrioritySection from '../../components/admin/AdminDashboardPrioritySection.vue'
import AdminDashboardTopCategoriesSection from '../../components/admin/AdminDashboardTopCategoriesSection.vue'
import AdminDashboardYearTotalsSection from '../../components/admin/AdminDashboardYearTotalsSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAdminDashboardOverview } from '../../components/admin/useAdminDashboardOverview'
const store = useAwardsStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const metricToneMap = {
Nominierungen: {
icon: Sparkles,
trend: 12.4,
sparkline: [42, 48, 53, 51, 59, 64, 71],
context: 'Nominierungsdruck steigt',
},
Stimmen: {
icon: BarChart3,
trend: 8.7,
sparkline: [54, 57, 63, 66, 72, 76, 81],
context: 'Voting-Aktivität stabil positiv',
},
Kategorien: {
icon: Tags,
trend: 0,
sparkline: [62, 62, 62, 63, 63, 63, 63],
context: 'Struktur bleibt konstant',
},
'Reviews offen': {
icon: Clock3,
trend: -6.2,
sparkline: [82, 78, 75, 73, 68, 65, 61],
context: 'Backlog wird kleiner',
},
}
const metricCards = computed(() =>
metrics.value.map((metric) => ({
...metric,
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
icon: BarChart3,
trend: 0,
sparkline: [50, 50, 50, 50, 50, 50, 50],
context: metric.note,
}),
})),
)
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
const yearTotals = computed(() => [
{
label: 'Nominierungen gesamt',
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
icon: Sparkles,
},
{
label: 'Stimmen gesamt',
value: getVoteMetricValue(metrics.value),
note: 'alle abgegebenen Votes',
icon: BarChart3,
},
{
label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length,
note: 'für Voting und Archiv gepflegt',
icon: Users,
},
{
label: 'Kategorien',
value: store.adminSeasonDetail.categories.length,
note: 'aktive Award-Kategorien',
icon: Tags,
},
{
label: 'Offene Reviews',
value: store.adminSeasonDetail.pendingNominations.length,
note: 'brauchen Team-Entscheidung',
icon: Clock3,
},
{
label: 'Risikohinweise',
value: store.admin.riskFlags.length,
note: 'aktuell offen',
icon: ShieldAlert,
},
])
const priorityActions = computed(() => [
{
label: 'Reviews bearbeiten',
value: store.adminSeasonDetail.pendingNominations.length,
to: '/admin/reviews',
hint: 'Freitext-Nominierungen warten auf Entscheidung',
icon: Sparkles,
tone: 'violet',
},
{
label: 'Risiko prüfen',
value: store.admin.riskFlags.length,
to: '/admin/risk',
hint: 'Auffällige Muster brauchen Sichtung',
icon: ShieldAlert,
tone: 'rose',
},
{
label: 'Kategorien pflegen',
value: store.adminSeasonDetail.categories.length,
to: '/admin/categories',
hint: 'Texte, Limits und Reihenfolge aktuell halten',
icon: Tags,
tone: 'amber',
},
{
label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length,
to: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users,
tone: 'emerald',
},
])
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
)
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
)
return [
{
label: 'Kategorien ohne Kandidaten',
value: categoriesWithoutCandidates.length,
to: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
},
{
label: 'Review-Backlog verteilt',
value: categoriesWithReviews.length,
to: '/admin/nominations',
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
},
{
label: 'Risk Flags offen',
value: store.admin.riskFlags.length,
to: '/admin/risk',
state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger',
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
},
]
})
const {
store,
activities,
topCategories,
metricCards,
openReviewCount,
openRiskCount,
liveSummary,
liveStatusBadge,
maxCategoryVotes,
totalCategoryVotes,
yearTotals,
priorityActions,
operationChecks,
} = useAdminDashboardOverview()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Dashboard"
title="Was braucht gerade Aufmerksamkeit?"
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebündelt, damit du schneller entscheiden kannst, was als Nächstes drankommt."
:icon="LayoutDashboard"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
Voting und Nominierungen ziehen an, während der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert.
</p>
</div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
+9.8% Gesamtaktivität
</div>
</div>
</div>
<AdminDashboardHeroSection
:metric-cards="metricCards"
:live-summary="liveSummary"
:live-status-badge="liveStatusBadge"
:open-risk-count="openRiskCount"
:open-review-count="openReviewCount"
/>
<div class="grid gap-4 p-5 md:grid-cols-2">
<div
v-for="metric in metricCards"
:key="metric.label"
class="rounded-[24px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-3 block text-4xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="metric.icon" class="h-5 w-5" />
</div>
</div>
<div class="mt-4 flex items-center justify-between gap-3">
<span
class="inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold"
:class="metric.trend < 0 ? 'bg-emerald-50 text-emerald-700' : metric.trend > 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
<ArrowDownRight v-if="metric.trend < 0" class="h-3.5 w-3.5" />
<ArrowUpRight v-else-if="metric.trend > 0" class="h-3.5 w-3.5" />
{{ metric.trend === 0 ? 'stabil' : `${metric.trend > 0 ? '+' : ''}${metric.trend}%` }}
</span>
<span class="text-xs text-slate-500">{{ metric.context }}</span>
</div>
<div class="mt-5 flex h-16 items-end gap-1.5">
<span
v-for="(value, index) in metric.sparkline"
:key="`${metric.label}-${index}`"
class="flex-1 rounded-t-full bg-gradient-to-t from-[#7c5cff] to-[#c4b5fd]"
:style="{ height: `${value}%` }"
/>
</div>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Schnellzugriffe</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was zuerst?</h2>
</div>
<Clock3 class="h-6 w-6 text-amber-500" />
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="item in priorityActions"
:key="item.label"
:to="item.to"
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<div
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
:class="{
'bg-violet-100 text-violet-700': item.tone === 'violet',
'bg-rose-100 text-rose-700': item.tone === 'rose',
'bg-amber-100 text-amber-700': item.tone === 'amber',
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
}"
>
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ item.label }}</p>
<p class="truncate text-sm text-slate-500">{{ item.hint }}</p>
</div>
</div>
<strong class="rounded-full border border-violet-100 bg-white px-3 py-1 text-violet-800">{{ item.value }}</strong>
</div>
</RouterLink>
</div>
</Card>
<AdminDashboardPrioritySection :priority-actions="priorityActions" />
</section>
<section class="grid gap-4 lg:grid-cols-3">
<RouterLink
v-for="check in operationChecks"
:key="check.label"
:to="check.to"
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="{
'border-emerald-100': check.state === 'ok',
'border-amber-100': check.state === 'warn',
'border-rose-100': check.state === 'danger',
}"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
{{ check.value }}
</strong>
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
</div>
</div>
</RouterLink>
</section>
<AdminDashboardChecksSection :operation-checks="operationChecks" />
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Jahreszahlen</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Gesamtmetriken {{ store.adminSeasonDetail.year }}</h2>
</div>
<BarChart3 class="h-6 w-6 text-amber-500" />
</div>
<AdminDashboardYearTotalsSection
:year="store.adminSeasonDetail.year"
:year-totals="yearTotals"
/>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<div
v-for="item in yearTotals"
:key="item.label"
class="rounded-[22px] border border-violet-100 bg-white/90 p-4"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.label }}</p>
<strong class="mt-2 block text-3xl text-violet-900">{{ item.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-4 w-4" />
</div>
</div>
<p class="mt-3 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
</div>
</Card>
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Stimmen</h2>
</div>
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
</div>
<div class="mt-6 space-y-4">
<div
v-for="(category, index) in topCategories"
:key="category.category"
class="rounded-[24px] border border-violet-100 bg-white/90 p-4"
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">#{{ index + 1 }}</p>
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
</div>
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
</div>
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
<div
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
/>
</div>
</div>
</div>
</Card>
<AdminDashboardTopCategoriesSection
:total-category-votes="totalCategoryVotes"
:max-category-votes="maxCategoryVotes"
:top-categories="topCategories"
/>
</section>
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2>
</div>
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
</div>
<div class="mt-6 grid gap-4 md:grid-cols-3">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[24px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
</div>
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine aktuellen Audit-Aktivitäten vorhanden.
</p>
</div>
</Card>
<AdminDashboardActivitySection :activities="activities" />
</div>
</template>
+61 -21
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed, ref, watch } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import {
AlertTriangle,
@@ -8,30 +8,33 @@ import {
ClipboardList,
Film,
LayoutDashboard,
FileClock,
Settings,
Sparkles,
Tags,
UserCog,
Trophy,
Users,
Vote,
FileText,
} from '@lucide/vue'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { getRiskMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const route = useRoute()
const store = useAwardsStore()
const authStore = useAuthStore()
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const navGroups = [
const fullNavGroups = computed(() => [
{
label: 'Betrieb',
items: [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null },
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Voting', to: '/admin/voting', description: 'Readiness und Sperren', icon: Vote, badge: () => `${getVoteMetricValue(store.admin.metrics)}` },
],
},
{
@@ -40,25 +43,40 @@ const navGroups = [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien prüfen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, badge: () => null },
],
},
{
label: 'Kontrolle',
items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
{ label: 'Team-Audit', to: '/admin/users-logs', description: 'Admin-Spuren', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${openRiskCount.value}` },
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, badge: () => null },
],
},
{
label: 'Auswertung',
items: [
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => `${store.admin.topCategories.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Public-Status und Checks', icon: Settings, badge: () => null },
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => null },
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, badge: () => `${store.adminSeasonDetail.results.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, badge: () => null },
],
},
]
])
const navGroups = computed(() => {
if (authStore.canManageAdminWorkspace) {
return fullNavGroups.value
}
const allowedRoutes = new Set(['/admin/content', '/admin/settings'])
return fullNavGroups.value
.map((group) => ({
...group,
items: group.items.filter((item) => allowedRoutes.has(item.to)),
}))
.filter((group) => group.items.length > 0)
})
const currentSeason = computed(() => store.adminSeasonDetail)
const seasonSummary = computed(() => [
@@ -71,16 +89,40 @@ function isActive(to: string) {
return route.path === to
}
onMounted(async () => {
if (!authStore.isAdmin) return
await store.initializeAdminWorkspace()
})
async function ensureAdminWorkspace() {
if (!authStore.hydrated || !authStore.canAccessAdmin || adminWorkspaceLoading.value) return
if (adminWorkspaceLoaded.value && store.apiMode === 'api') return
adminWorkspaceLoading.value = true
try {
if (authStore.canManageAdminWorkspace) {
await store.initializeAdminWorkspace()
} else {
await store.loadAdminContentWorkspace()
}
adminWorkspaceLoaded.value = store.apiMode === 'api'
} finally {
adminWorkspaceLoading.value = false
}
}
watch(
() => [authStore.hydrated, authStore.canAccessAdmin, authStore.canManageAdminWorkspace, route.path] as const,
() => {
void ensureAdminWorkspace()
},
{ immediate: true },
)
</script>
<template>
<div class="pb-10">
<div class="grid gap-6 xl:grid-cols-[292px_minmax(0,1fr)]">
<aside class="space-y-3 xl:sticky xl:top-4 xl:h-fit">
<main class="order-1 min-w-0 xl:order-2">
<RouterView />
</main>
<aside class="order-2 space-y-3 xl:order-1 xl:sticky xl:top-4 xl:h-fit">
<Card class="p-3">
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
@@ -133,8 +175,6 @@ onMounted(async () => {
</div>
</Card>
</aside>
<RouterView />
</div>
</div>
</template>
@@ -1,17 +1,21 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const route = useRoute()
const router = useRouter()
const query = ref('')
const categoryFilter = ref<number | null>(null)
const statusFilter = ref<'all' | 'selected' | 'empty-category' | 'heavy'>('all')
const reviewModalOpen = ref(false)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryMap = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category])))
@@ -48,19 +52,49 @@ const nominationStats = computed(() => [
{ label: 'Betroffene Kategorien', value: categoryStats.value.filter((category) => category.pending > 0).length, icon: Tags },
{ label: 'Kandidatenbasis', value: seasonDetail.value.candidates.length, icon: Users },
])
const reviewFocusCategories = computed(() => categoryStats.value.filter((category) => category.pending > 0).slice(0, 3))
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: seasonDetail.value.pendingNominations.length },
{ key: 'empty-category' as const, label: 'Ohne Kandidatenbasis', count: seasonDetail.value.pendingNominations.filter((nomination) => !seasonDetail.value.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)).length },
{ key: 'heavy' as const, label: 'Hoher Druck', count: categoryStats.value.filter((category) => category.pending >= 3).reduce((sum, category) => sum + category.pending, 0) },
])
function openReviewModal(nominationId?: number) {
const nextQuery: LocationQueryRaw = { ...route.query, review: '1' }
if (nominationId) {
nextQuery.nominationId = String(nominationId)
} else {
delete nextQuery.nominationId
}
reviewModalOpen.value = true
void router.replace({ name: 'admin-nominations', query: nextQuery })
}
function closeReviewModal() {
const restQuery: LocationQueryRaw = { ...route.query }
delete restQuery.review
delete restQuery.nominationId
reviewModalOpen.value = false
void router.replace({ name: 'admin-nominations', query: restQuery })
}
watch(
() => [route.query.review, route.query.nominationId] as const,
([review, nominationId]) => {
reviewModalOpen.value = Boolean(review || nominationId)
},
{ immediate: true },
)
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Nominierungen"
title="Eingang und Backlog verstehen"
description="Hier siehst du, wo Freitext-Nominierungen auflaufen. Die eigentliche Entscheidung bleibt im Review-Bereich, aber diese Ansicht zeigt dir schneller, welche Kategorien Aufmerksamkeit brauchen."
description="Nominierungen sichten, Kategorien priorisieren und Review-Fälle fokussiert entscheiden."
:icon="ClipboardList"
/>
@@ -80,11 +114,41 @@ const statusFilters = computed(() => [
</Card>
</section>
<Card class="overflow-hidden border-amber-100 bg-amber-50/55">
<div class="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-amber-700">Review-Fokus</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">
{{ seasonDetail.pendingNominations.length }} offene Entscheidungen
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-600">
Die Nominierungsseite bleibt eine Übersicht. Der Review-Fokus öffnet die Queue mit Entscheidungspanel, ohne die Liste dauerhaft aufzublähen.
</p>
<div v-if="reviewFocusCategories.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="category in reviewFocusCategories"
:key="category.id"
class="rounded-full border border-amber-200 bg-white/75 px-3 py-1 text-xs font-semibold text-amber-800"
>
{{ category.name }} · {{ category.pending }}
</span>
</div>
</div>
<button
type="button"
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal()"
>
Review-Fokus öffnen
</button>
</div>
</Card>
<section class="grid gap-6 xl:grid-cols-[0.92fr_1.08fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Wo staut es sich?</h2>
<h2 class="mt-1 text-xl font-bold text-slate-900">Wo staut es sich?</h2>
</div>
<div class="divide-y divide-violet-50">
<button
@@ -115,9 +179,13 @@ const statusFilters = computed(() => [
placeholder="Nach Kandidat, User oder Kategorie suchen"
/>
</label>
<RouterLink to="/admin/reviews" class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500">
Reviews öffnen
</RouterLink>
<button
type="button"
class="inline-flex h-12 items-center justify-center rounded-2xl bg-violet-600 px-5 text-sm font-semibold text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal()"
>
Review-Fokus
</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
@@ -152,6 +220,13 @@ const statusFilters = computed(() => [
>
erst Kandidatenbasis klären
</span>
<button
type="button"
class="inline-flex h-9 items-center justify-center rounded-full bg-violet-600 px-4 text-xs font-semibold text-white shadow-sm shadow-violet-500/20 transition hover:bg-violet-500"
@click="openReviewModal(nomination.id)"
>
Fall entscheiden
</button>
</div>
</div>
<p v-if="filteredNominations.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
@@ -160,5 +235,7 @@ const statusFilters = computed(() => [
</div>
</Card>
</section>
<AdminNominationReviewModal :open="reviewModalOpen" @close="closeReviewModal" />
</div>
</template>
+58 -292
View File
@@ -1,323 +1,89 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, Search, Sparkles, Trash2 } from '@lucide/vue'
import { Sparkles } from '@lucide/vue'
import AdminReviewDecisionPanel from '../../components/admin/AdminReviewDecisionPanel.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminReviewsHistorySection from '../../components/admin/AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from '../../components/admin/AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from '../../components/admin/AdminReviewsQueueList.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
import { useAdminReviewsManager } from '../../components/admin/useAdminReviewsManager'
const store = useAwardsStore()
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const reviewFilter = ref('')
const categoryFilter = ref<number | null>(null)
const selectedNominationId = ref<number | null>(null)
const filteredNominations = computed(() => {
const query = reviewFilter.value.trim().toLowerCase()
return seasonDetail.value.pendingNominations.filter((nomination) =>
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
.join(' ')
.toLowerCase()
.includes(query)),
)
})
const selectedNomination = computed(() =>
filteredNominations.value.find((nomination) => nomination.id === selectedNominationId.value) ?? filteredNominations.value[0] ?? null,
)
const reviewStats = computed(() => [
{ label: 'Offen', value: seasonDetail.value.pendingNominations.length },
{ label: 'Sichtbar', value: filteredNominations.value.length },
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
])
const categoryOptions = computed(() =>
seasonDetail.value.categories
.filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
.map((category) => ({
id: category.id,
label: category.name,
count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
})),
)
const selectedCandidateCollision = computed(() => {
if (!selectedNomination.value) return null
const form = reviewForms[selectedNomination.value.id]
if (!form) return null
const normalizedName = form.displayName.trim().toLowerCase()
const normalizedSlug = form.channelSlug.trim().toLowerCase()
return seasonDetail.value.candidates.find((candidate) =>
candidate.categoryId === selectedNomination.value?.categoryId &&
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
) ?? null
})
const canApproveSelected = computed(() => {
if (!selectedNomination.value) return false
const form = reviewForms[selectedNomination.value.id]
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim())
})
watch(
const {
reviewSaving,
adminMessage,
adminError,
reviewForms,
seasonDetail,
(detail) => {
for (const nomination of detail.pendingNominations) {
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: '',
platform: 'Twitch',
}
}
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
}
},
{ immediate: true },
)
watch(
reviewFilter,
categoryFilter,
selectedNominationId,
candidatePlatformOptions,
filteredNominations,
(nominations) => {
if (!nominations.some((nomination) => nomination.id === selectedNominationId.value)) {
selectedNominationId.value = nominations[0]?.id ?? null
}
},
{ immediate: true },
)
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
function setPlatform(platform: string) {
if (!selectedNomination.value) return
reviewForms[selectedNomination.value.id].platform = platform
}
selectedNomination,
reviewStats,
reviewedNominations,
categoryOptions,
selectedCandidateCollision,
canApproveSelected,
approveNomination,
rejectNomination,
selectedPlatformValue,
handlePlatformSelection,
} = useAdminReviewsManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Reviews"
title="Freitext-Nominierungen sichten"
description="Alle uneindeutigen oder noch nicht gemappten Nominierungen laufen hier zusammen. Jede Entscheidung muss einen vollständigen Kandidaten-Datensatz erzeugen oder den Fall bewusst verwerfen."
description="Freitext-Nominierungen annehmen, in Kandidaten umwandeln oder verwerfen."
:icon="Sparkles"
/>
<AdminSeasonToolbar />
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-white/75 p-6">
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
</p>
</div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="reviewFilter"
type="text"
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="Nach Kategorie, Kandidat oder Nutzer suchen"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ filteredNominations.length }} / {{ seasonDetail.pendingNominations.length }} sichtbar
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="categoryFilter = null"
>
Alle Kategorien
</button>
<button
v-for="category in categoryOptions"
:key="category.id"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="categoryFilter = category.id"
>
{{ category.label }} · {{ category.count }}
</button>
</div>
</div>
<AdminReviewsQueueHeader
v-model:review-filter="reviewFilter"
v-model:category-filter="categoryFilter"
:total-pending="seasonDetail.pendingNominations.length"
:visible-count="filteredNominations.length"
:review-stats="reviewStats"
:category-options="categoryOptions"
/>
<div class="space-y-4 p-6">
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
<button
v-for="nomination in filteredNominations"
:key="nomination.id"
type="button"
class="w-full rounded-2xl border p-3 text-left transition"
:class="selectedNomination?.id === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
@click="selectedNominationId = nomination.id"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
{{ nomination.categoryName }}
</span>
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
ID {{ nomination.id }}
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
</div>
</button>
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
</p>
<p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Fälle passen zum aktuellen Filter.
</p>
</div>
<div v-if="selectedNomination" 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">{{ selectedNomination.categoryName }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedNomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Eingereicht von {{ selectedNomination.submittedByTwitchId }} · {{ new Date(selectedNomination.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 {{ selectedNomination.id }}
</div>
</div>
<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="reviewForms[selectedNomination.id].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="reviewForms[selectedNomination.id].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>
<input
v-model="reviewForms[selectedNomination.id].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="Twitch"
/>
</label>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="platform in ['Twitch', 'YouTube', 'TikTok']"
:key="platform"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="reviewForms[selectedNomination.id].platform === platform ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-white/70 text-slate-600 hover:bg-white'"
@click="setPlatform(platform)"
>
{{ platform }}
</button>
</div>
<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>
<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>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === selectedNomination.id" variant="secondary" @click="rejectNomination(selectedNomination.id)">
<Trash2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === selectedNomination.id || !canApproveSelected" @click="approveNomination(selectedNomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button>
</div>
</div>
<AdminReviewDecisionPanel
:nomination="selectedNomination"
:review-saving="reviewSaving"
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
:candidate-platform-options="candidatePlatformOptions"
:selected-candidate-collision="selectedCandidateCollision"
:can-approve-selected="canApproveSelected"
:selected-platform-value="selectedPlatformValue"
@platform-change="handlePlatformSelection"
@approve="approveNomination"
@reject="rejectNomination"
/>
</div>
<AdminReviewsHistorySection
:reviewed-nominations="reviewedNominations"
:reviewed-total="seasonDetail.reviewedNominations.length"
/>
</div>
</Card>
</div>
+124 -157
View File
@@ -1,177 +1,144 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Search, ShieldAlert } from '@lucide/vue'
import { ShieldAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Button from '../../components/ui/Button.vue'
import AdminRiskDecisionPanel from '../../components/admin/AdminRiskDecisionPanel.vue'
import AdminRiskHistorySection from '../../components/admin/AdminRiskHistorySection.vue'
import AdminRiskOverviewBoard from '../../components/admin/AdminRiskOverviewBoard.vue'
import AdminRiskQueueList from '../../components/admin/AdminRiskQueueList.vue'
import AdminRiskRulesEditor from '../../components/admin/AdminRiskRulesEditor.vue'
import { useAdminRiskManager } from '../../components/admin/useAdminRiskManager'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const riskSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const riskFilter = ref('')
const severityFilter = ref<'all' | 'high' | 'medium' | 'low'>('all')
const riskFlags = computed(() => store.admin.riskFlags)
const filteredRiskFlags = computed(() => {
const query = riskFilter.value.trim().toLowerCase()
return riskFlags.value.filter((flag) =>
(severityFilter.value === 'all' || flag.severity.toLowerCase() === severityFilter.value) &&
(!query || [flag.source, flag.type, flag.summary, flag.twitchUserId ?? '', flag.createdFromIp]
.join(' ')
.toLowerCase()
.includes(query)),
)
})
const riskStats = computed(() => [
{ label: 'Offen', value: riskFlags.value.length },
{ label: 'High', value: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
{ label: 'User betroffen', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
])
const severityFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: riskFlags.value.length },
{ key: 'high' as const, label: 'High', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'high').length },
{ key: 'medium' as const, label: 'Medium', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'medium').length },
{ key: 'low' as const, label: 'Low', count: riskFlags.value.filter((flag) => flag.severity.toLowerCase() === 'low').length },
])
async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
riskSaving.value = riskFlagId
adminMessage.value = ''
adminError.value = ''
try {
await store.resolveRiskFlag(riskFlagId, status)
adminMessage.value = `Risikohinweis ${riskFlagId} wurde aktualisiert.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Risikohinweis konnte nicht aktualisiert werden.'
} finally {
riskSaving.value = null
}
}
const {
riskSaving,
riskLoading,
adminMessage,
adminError,
riskFilter,
severityFilter,
historyStatusFilter,
selectedRiskFlagId,
selectedDecisionNote,
selectedDecisionNoteLength,
selectedDecisionReady,
queuePage,
queuePageLabel,
queueHasPrevious,
queueHasMore,
historyPage,
historyPageLabel,
historyHasPrevious,
historyHasMore,
selectedBulkRiskFlagIds,
bulkReviewNote,
bulkSaving,
canBulkResolve,
riskRules,
riskRulesLoading,
riskRulesSaving,
riskFlags,
riskHistory,
filteredRiskFlags,
selectedRiskFlag,
selectedRiskMetadata,
riskStats,
riskHistoryStats,
recentRiskHistory,
severityFilters,
historyStatusFilters,
riskLoadedLabel,
loadRiskFlags,
updateRiskFlagStatus,
setQueuePage,
setHistoryPage,
toggleBulkRiskFlag,
selectVisibleLowRiskFlags,
clearBulkSelection,
bulkResolveRiskFlags,
updateRiskRule,
saveRiskRules,
} = useAdminRiskManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Risiko"
title="Auffällige Muster entscheiden"
description="Dieser Bereich ist nur für operative Risiko-Sichtung zuständig: Voting-, Login- und Einreichungsmuster prüfen, verwerfen oder erledigt markieren. Audit-Logs liegen separat im Team-Audit."
description="Auffällige Voting-, Login- und Einreichungsmuster prüfen."
:icon="ShieldAlert"
/>
<div class="grid gap-6 xl:grid-cols-[0.82fr_1.18fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikoprüfung</h2>
<p class="mt-2 text-sm text-slate-500">Auffällige Login-, Nominierungs- und Voting-Muster für die manuelle Sichtung.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
</span>
</div>
<div class="mt-5 grid gap-2 sm:grid-cols-3">
<div v-for="stat in riskStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
<Card class="overflow-hidden">
<AdminRiskOverviewBoard
v-model:risk-filter="riskFilter"
v-model:severity-filter="severityFilter"
:filtered-count="filteredRiskFlags.length"
:total-open="riskFlags.length"
:loaded-label="riskLoadedLabel"
:loading="riskLoading"
:message="adminMessage"
:error="adminError"
:stats="riskStats"
:severity-filters="severityFilters"
@refresh="loadRiskFlags"
/>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
<div class="grid gap-5 p-6 xl:grid-cols-[minmax(300px,0.78fr)_minmax(0,1.22fr)]">
<AdminRiskQueueList
:risk-flags="filteredRiskFlags"
:total-open="riskFlags.length"
:selected-risk-flag-id="selectedRiskFlagId"
:page="queuePage"
:page-label="queuePageLabel"
:has-previous="queueHasPrevious"
:has-more="queueHasMore"
:selected-bulk-risk-flag-ids="selectedBulkRiskFlagIds"
:bulk-review-note="bulkReviewNote"
:bulk-saving="bulkSaving"
:can-bulk-resolve="canBulkResolve"
@select="selectedRiskFlagId = $event"
@page="setQueuePage"
@toggle-bulk="toggleBulkRiskFlag"
@select-visible-low="selectVisibleLowRiskFlags"
@clear-bulk="clearBulkSelection"
@update:bulk-review-note="bulkReviewNote = $event"
@bulk-decide="bulkResolveRiskFlags"
/>
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="riskFilter"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Typ, Nutzer oder IP filtern"
/>
</label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprüften Fall.
</div>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="filter in severityFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="severityFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
<AdminRiskDecisionPanel
v-model:decision-note="selectedDecisionNote"
:risk-flag="selectedRiskFlag"
:metadata-items="selectedRiskMetadata"
:decision-note-length="selectedDecisionNoteLength"
:decision-ready="selectedDecisionReady"
:saving="riskSaving"
@decide="updateRiskFlagStatus"
/>
</div>
<div class="mt-6 space-y-4">
<div
v-for="flag in filteredRiskFlags"
:key="flag.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ flag.source }} · {{ flag.type }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ flag.summary }}</h3>
<p class="mt-2 text-sm text-slate-500">
{{ flag.twitchUserId || 'unbekannter User' }} · {{ flag.createdFromIp }} · {{ new Date(flag.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 uppercase tracking-[0.2em] text-slate-600">
{{ flag.severity }}
</div>
</div>
<AdminRiskRulesEditor
:rules="riskRules"
:loading="riskRulesLoading"
:saving="riskRulesSaving"
@update-rule="updateRiskRule"
@save="saveRiskRules"
/>
<pre class="mt-4 overflow-x-auto rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-xs text-slate-600">{{ flag.metadataJson }}</pre>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="riskSaving === flag.id" variant="secondary" @click="resolveRiskFlag(flag.id, 'dismissed')">
{{ riskSaving === flag.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="riskSaving === flag.id" @click="resolveRiskFlag(flag.id, 'resolved')">
{{ riskSaving === flag.id ? 'Speichert ...' : 'Erledigt markieren' }}
</Button>
</div>
</div>
<p v-if="riskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Risikohinweise vorhanden.
</p>
<p v-else-if="filteredRiskFlags.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Risikohinweise passen zum aktuellen Filter.
</p>
</div>
</Card>
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review-Protokoll</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Risk Playbook</h2>
<div class="mt-6 space-y-3">
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">1. Quelle prüfen</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Vote-Flags vor Ergebnisfreigabe priorisieren, Clip-Flags vor Public-Einbindung, Login-Flags bei wiederholten IP-Mustern.</p>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">2. Entscheidung dokumentieren</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Erledigt bedeutet geprüft und relevant; Verwerfen bedeutet false positive oder kein Award-Risiko.</p>
</div>
<div class="rounded-[22px] border border-violet-100 bg-white/90 p-4">
<p class="font-semibold text-slate-900">3. Audit separat lesen</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Admin-Aktionen findest du im Team-Audit, damit Risikoentscheidungen nicht mit normalen Bearbeitungen vermischt werden.</p>
</div>
</div>
</Card>
</div>
<AdminRiskHistorySection
v-model:history-status-filter="historyStatusFilter"
:risk-history="recentRiskHistory"
:total-history="riskHistory.length"
:history-status-filters="historyStatusFilters"
:history-stats="riskHistoryStats"
:saving="riskSaving"
:page="historyPage"
:page-label="historyPageLabel"
:has-previous="historyHasPrevious"
:has-more="historyHasMore"
@decide="updateRiskFlagStatus"
@page="setHistoryPage"
/>
</Card>
</div>
</template>
+105 -210
View File
@@ -1,230 +1,125 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { CalendarCog, CheckCircle2, Clock3, Layers3, ShieldCheck, Tags, Users } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { CalendarCog } from '@lucide/vue'
import AdminAwardYearsPanel from '../../components/admin/AdminAwardYearsPanel.vue'
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
import AdminSeasonPhaseSwitcher from '../../components/admin/AdminSeasonPhaseSwitcher.vue'
import AdminSeasonStatusCard from '../../components/admin/AdminSeasonStatusCard.vue'
import AdminSeasonTimelineEditor from '../../components/admin/AdminSeasonTimelineEditor.vue'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
const store = useAwardsStore()
const saving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const form = reactive({
currentPhase: '',
isCurrent: false,
})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const selectedSeason = computed(() =>
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
)
const seasonHealth = computed(() => {
const emptyCategories = seasonDetail.value.categories.filter((category) =>
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
).length
return [
{
label: 'Kategorien',
value: seasonDetail.value.categories.length,
note: emptyCategories === 0 ? 'alle mit Kandidatenbasis' : `${emptyCategories} ohne Kandidaten`,
icon: Tags,
to: '/admin/categories',
},
{
label: 'Kandidaten',
value: seasonDetail.value.candidates.length,
note: 'für Public Voting und Archiv',
icon: Users,
to: '/admin/candidates',
},
{
label: 'Offene Reviews',
value: seasonDetail.value.pendingNominations.length,
note: 'vor Voting-Freeze entscheiden',
icon: ShieldCheck,
to: '/admin/reviews',
},
]
})
const phasePresets = ['Vorbereitung', 'Nominierung', 'Community Voting', 'Auswertung', 'Award Show', 'Archiviert']
watch(
const {
store,
form,
createForm,
saving,
adminMessage,
adminError,
createModalOpen,
creating,
completing,
seasonToDelete,
deleting,
seasonDetail,
(detail) => {
form.currentPhase = detail.currentPhase
form.isCurrent = detail.isCurrent
},
{ immediate: true },
)
async function saveSeason() {
if (!selectedSeasonId.value) return
saving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: form.currentPhase,
isCurrent: form.isCurrent,
})
adminMessage.value = 'Jahresstatus gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
} finally {
saving.value = false
}
}
selectedSeasonId,
selectedSeason,
readinessItems,
archiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
canDeleteSelectedSeason,
canCompleteSelectedSeason,
copySourceOptions,
loadingSeasonAudit,
latestSeasonAuditSummary,
latestSeasonAuditMeta,
canCreate,
activatePhase,
openCreateModal,
saveSeason,
completeSeason,
createSeason,
openDeleteSeasonModal,
confirmDeleteSeason,
} = useAdminSeasonManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Jahre"
title="Award-Jahr steuern"
description="Hier liegt nur die Season-Verantwortung: Jahr auswählen, Phase setzen und entscheiden, welches Jahr öffentlich sichtbar ist. Kategorien und Kandidaten bleiben in ihren eigenen Arbeitsbereichen."
description="Jahr, Phase und öffentliche Sichtbarkeit steuern."
:icon="CalendarCog"
/>
<AdminSeasonToolbar />
<section class="space-y-6">
<AdminSeasonPhaseSwitcher
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:selected-season-id="selectedSeasonId"
:activate-phase="activatePhase"
/>
<section class="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ seasonDetail.name || 'Kein Jahr gewählt' }}</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Der Status steuert die Admin-Orientierung und den Public-Kontext. Inhaltliche Pflege passiert über Kategorien, Kandidaten, Reviews und Clips.
</p>
</div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="form.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
{{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div>
</div>
</div>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<AdminSeasonStatusCard
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:completing="completing"
:admin-message="adminMessage"
:admin-error="adminError"
:readiness-items="readinessItems"
:archive-readiness-issues="archiveReadinessIssues"
:can-activate-public="canActivatePublic"
:loading-season-audit="loadingSeasonAudit"
:latest-season-audit-summary="latestSeasonAuditSummary"
:latest-season-audit-meta="latestSeasonAuditMeta"
:selected-season-id="selectedSeasonId"
:can-delete-selected-season="canDeleteSelectedSeason"
:can-complete-selected-season="canCompleteSelectedSeason"
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
:open-delete-season-modal="openDeleteSeasonModal"
:save-season="saveSeason"
:complete-season="completeSeason"
/>
<div class="space-y-5 p-6">
<div class="grid gap-3 sm:grid-cols-2">
<button
v-for="phase in phasePresets"
:key="phase"
type="button"
class="rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition"
:class="form.currentPhase === phase ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="form.currentPhase = phase"
>
{{ phase }}
</button>
</div>
<AdminAwardYearsPanel
:seasons="store.adminSeasons"
:selected-season-id="selectedSeasonId"
:open-create-modal="openCreateModal"
:load-season-detail="store.loadAdminSeasonDetail"
/>
</section>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aktuelle Phase</span>
<input
v-model="form.currentPhase"
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. Community Voting"
/>
</label>
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span>
<span class="block font-semibold text-slate-800">Dieses Award-Jahr öffentlich schalten</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">
Nur ein Award-Jahr sollte gleichzeitig als Public-Kontext aktiv sein.
</span>
</span>
</label>
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="flex justify-end border-t border-violet-100 pt-5">
<Button :disabled="saving || !selectedSeasonId" @click="saveSeason">
{{ saving ? 'Speichert ...' : 'Jahresstatus speichern' }}
</Button>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-start gap-4">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Layers3 class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Season Snapshot</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ seasonDetail.year || '-' }}</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
Schneller Überblick, ob das gewählte Jahr bereit für die nächste Award-Phase ist.
</p>
</div>
</div>
<div class="mt-6 space-y-3">
<RouterLink
v-for="item in seasonHealth"
:key="item.label"
:to="item.to"
class="flex items-center justify-between gap-4 rounded-[22px] border border-violet-100 bg-white/90 p-4 transition hover:bg-violet-50/50"
>
<div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="item.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="truncate text-sm text-slate-500">{{ item.note }}</p>
</div>
</div>
<strong class="text-xl text-violet-800">{{ item.value }}</strong>
</RouterLink>
</div>
</Card>
<AdminSeasonTimelineEditor
:form="form"
:saving="saving"
:selected-season-id="selectedSeasonId"
:save-season="saveSeason"
/>
</section>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Alle Jahre</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Season-Liste</h2>
</div>
<div class="divide-y divide-violet-50">
<button
v-for="season in store.adminSeasons"
:key="season.id"
type="button"
class="grid w-full gap-3 px-5 py-4 text-left transition hover:bg-violet-50/50 md:grid-cols-[120px_minmax(0,1fr)_180px_120px] md:items-center"
:class="selectedSeason?.id === season.id ? 'bg-violet-50/80' : ''"
@click="store.loadAdminSeasonDetail(season.id)"
>
<strong class="text-violet-800">{{ season.year }}</strong>
<span class="min-w-0">
<span class="block truncate font-semibold text-slate-900">{{ season.name }}</span>
<span class="mt-1 block truncate text-sm text-slate-500">{{ season.categoryCount }} Kategorien</span>
</span>
<span class="inline-flex w-fit items-center gap-2 rounded-full border border-violet-100 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
<Clock3 class="h-3.5 w-3.5 text-violet-500" />
{{ season.currentPhase }}
</span>
<span class="inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold" :class="season.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
<CheckCircle2 class="h-3.5 w-3.5" />
{{ season.isCurrent ? 'Public' : 'Intern' }}
</span>
</button>
<p v-if="store.adminSeasons.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Noch keine Award-Jahre aus der API geladen.
</p>
</div>
</Card>
</div>
<AdminSeasonCreateModal
:open="createModalOpen"
:create-form="createForm"
:creating="creating"
:can-create="canCreate"
:copy-source-options="copySourceOptions"
:create-public-readiness-issues="createPublicReadinessIssues"
:phase-presets="phasePresets"
:on-close="() => { createModalOpen = false }"
:on-create="createSeason"
/>
<AdminSeasonDeleteModal
:season-to-delete="seasonToDelete"
:deleting="deleting"
:on-close="() => { seasonToDelete = null }"
:on-confirm-delete="confirmDeleteSeason"
/>
</template>
+88 -122
View File
@@ -1,143 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2, Database, Settings, ShieldCheck, Tags, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { Settings } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AdminOperationalSettingsCard from '../../components/admin/AdminOperationalSettingsCard.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
import { useAuthStore } from '../../stores/auth'
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const hasVotingPhase = computed(() => seasonDetail.value.currentPhase.toLowerCase().includes('voting'))
const categoriesWithoutCandidates = computed(() =>
seasonDetail.value.categories.filter((category) =>
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
),
)
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const authStore = useAuthStore()
const canManageOperationalSettings = computed(() => authStore.canManageOperationalSettings)
const checks = computed(() => [
{
label: 'Backend verbunden',
value: store.apiMode === 'api',
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
icon: Database,
to: null,
},
{
label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
icon: CheckCircle2,
to: '/admin/years',
},
{
label: 'Voting-Basis vollständig',
value: categoriesWithoutCandidates.value.length === 0 && seasonDetail.value.categories.length > 0,
note: categoriesWithoutCandidates.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${categoriesWithoutCandidates.value.length} Kategorien brauchen Kandidaten.`,
icon: Tags,
to: '/admin/categories',
},
{
label: 'Risiko-Queue leer',
value: store.admin.riskFlags.length === 0,
note: `${store.admin.riskFlags.length} offene Risikohinweise im Admin-Kontext.`,
icon: ShieldCheck,
to: '/admin/risk',
},
])
const gates = computed(() => [
{
label: 'Nominierungen',
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
note: 'Aktiv, wenn die Season-Phase auf Nominierung steht.',
to: '/admin/years',
},
{
label: 'Voting',
state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
to: '/admin/voting',
},
{
label: 'Clip-Moderation',
state: pendingClips.value > 0,
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
to: '/admin/clips',
},
{
label: 'Review-Freeze',
state: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Freitext-Reviews.`,
to: '/admin/reviews',
},
])
const {
healthLoading,
healthError,
databaseHealth,
pendingMigrationCount,
healthLoadedLabel,
contentChecks,
contentCompletion,
checks,
gates,
refreshDatabaseHealth,
} = useAdminSettingsOverview()
const {
operationalLoading,
operationalSaving,
operationalError,
operationalSuccess,
operationalForm,
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
demoPasswordHint,
demoCredentialsComplete,
operationalSummary,
hasUnsavedOperationalChanges,
saveOperationalSettings,
} = useAdminOperationalSettings()
function confirmDiscardOperationalChanges() {
if (!hasUnsavedOperationalChanges.value) {
return true
}
return window.confirm('Du hast ungespeicherte Änderungen in Demo & Wartung. Änderungen verwerfen?')
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedOperationalChanges.value) return
event.preventDefault()
event.returnValue = ''
}
onBeforeRouteLeave(() => confirmDiscardOperationalChanges())
onMounted(() => window.addEventListener('beforeunload', handleBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnload))
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Einstellungen"
title="Systemchecks ohne Doppelpflege"
description="Diese Seite speichert keine Season-Daten mehr. Sie zeigt, ob API, Public-Jahr, Voting-Basis, Reviews, Clips und Risiko-Queue für den Award-Betrieb gesund sind."
description="Demo-Zugang, Wartungsmodus und Healthchecks."
:icon="Settings"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<RouterLink
v-for="check in checks"
:key="check.label"
:to="check.to ?? '/admin/settings'"
class="rounded-[26px] border bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.09)] transition hover:bg-violet-50/50"
:class="check.value ? 'border-emerald-100' : 'border-amber-100'"
>
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="check.value ? 'text-emerald-600' : 'text-amber-600'">{{ check.label }}</p>
<p class="mt-3 text-sm leading-6 text-slate-600">{{ check.note }}</p>
</div>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl" :class="check.value ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
<component :is="check.icon" class="h-5 w-5" />
</div>
</div>
</RouterLink>
</section>
<AdminSettingsOverviewBoard
:checks="checks"
:gates="gates"
:content-checks="contentChecks"
:content-completion="contentCompletion"
/>
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Feature Gates</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was ist wirklich aktiv?</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Die Gates sind aus aktuellen Daten abgeleitet und verlinken zum Ort, an dem der Zustand behoben wird.
</p>
</div>
<Vote class="h-6 w-6 text-violet-500" />
</div>
<AdminOperationalSettingsCard
v-model:demo-password="demoPasswordInput"
:form="operationalForm"
:loading="operationalLoading"
:saving="operationalSaving"
:error="operationalError"
:success="operationalSuccess"
:demo-password-hint="demoPasswordHint"
:demo-password-set="demoPasswordSet"
:demo-managed-by-database="demoManagedByDatabase"
:demo-credentials-complete="demoCredentialsComplete"
:summary="operationalSummary"
:dirty="hasUnsavedOperationalChanges"
:can-manage="canManageOperationalSettings"
@save="saveOperationalSettings"
/>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<RouterLink
v-for="gate in gates"
:key="gate.label"
:to="gate.to"
class="rounded-[22px] border p-4 transition hover:bg-violet-50/50"
:class="gate.state ? 'border-emerald-100 bg-emerald-50/40' : 'border-slate-100 bg-slate-50/70'"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ gate.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ gate.note }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="gate.state ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-600'">
{{ gate.state ? 'aktiv' : 'inaktiv' }}
</span>
</div>
</RouterLink>
</div>
</Card>
<AdminSettingsDatabaseCard
:health-loading="healthLoading"
:health-error="healthError"
:database-health="databaseHealth"
:pending-migration-count="pendingMigrationCount"
:health-loaded-label="healthLoadedLabel"
:refresh-database-health="refreshDatabaseHealth"
/>
</div>
</template>
+84 -108
View File
@@ -1,126 +1,102 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FileClock, Search, UserCog } from '@lucide/vue'
import { UserCog } from '@lucide/vue'
import AdminAuditDetailDrawer from '../../components/admin/AdminAuditDetailDrawer.vue'
import AdminAuditFocusPanel from '../../components/admin/AdminAuditFocusPanel.vue'
import AdminAuditLogList from '../../components/admin/AdminAuditLogList.vue'
import AdminAuditOverviewBar from '../../components/admin/AdminAuditOverviewBar.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Card from '../../components/ui/Card.vue'
import { useAwardsStore } from '../../stores/awards'
import { useAdminAuditManager } from '../../components/admin/useAdminAuditManager'
const store = useAwardsStore()
const query = ref('')
const auditEntries = computed(() => store.admin.auditEntries)
const filteredAuditEntries = computed(() => {
const search = query.value.trim().toLowerCase()
if (!search) return auditEntries.value
return auditEntries.value.filter((entry) =>
[entry.adminTwitchUserId, entry.actionType, entry.entityType, entry.entityId, entry.summary]
.join(' ')
.toLowerCase()
.includes(search),
)
})
const adminCounts = computed(() => {
const counts = new Map<string, number>()
for (const entry of auditEntries.value) counts.set(entry.adminTwitchUserId, (counts.get(entry.adminTwitchUserId) ?? 0) + 1)
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
})
const logStats = computed(() => [
{ label: 'Audit-Einträge', value: auditEntries.value.length },
{ label: 'Admins aktiv', value: adminCounts.value.length },
{ label: 'Sichtbar', value: filteredAuditEntries.value.length },
])
const actionCounts = computed(() => {
const counts = new Map<string, number>()
for (const entry of auditEntries.value) counts.set(entry.actionType, (counts.get(entry.actionType) ?? 0) + 1)
return [...counts.entries()]
.map(([action, count]) => ({ action, count }))
.sort((a, b) => b.count - a.count || a.action.localeCompare(b.action))
})
const {
query,
selectedAdmin,
selectedAction,
entityFilter,
fromDate,
toDate,
loadingAudit,
loadingMore,
auditError,
exportMessage,
selectedEntry,
auditRows,
adminCounts,
actionCounts,
entityCounts,
logStats,
focusCards,
activeFilterCount,
lastLoadedLabel,
emptyStateText,
pageSummaryLabel,
hasMore,
filterPresets,
appliedPresetKey,
entityOptions,
loadAuditEntries,
loadNextPage,
exportAuditCsv,
clearFilters,
applyPreset,
openAuditEntry,
closeAuditEntry,
} = useAdminAuditManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Team-Audit"
title="Admin-Aktionen nachvollziehen"
description="Diese Seite ist die Log-Quelle für Team-Handlungen: wer hat Kategorien, Kandidaten, Clips, Reviews oder Risk-Flags bearbeitet. Risikoentscheidungen selbst bleiben im Risiko-Bereich."
eyebrow="Audit-Log"
description="Nachvollziehbare Admin-Aktionen mit Suche, Metadaten, Quick-Filtern und CSV-Export."
:icon="UserCog"
/>
<Card class="p-5">
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_420px]">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Nach Admin, Aktion, User, IP oder Objekt suchen" />
</label>
<div class="grid grid-cols-3 gap-2">
<div v-for="stat in logStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
<strong class="text-lg text-violet-800">{{ stat.value }}</strong>
</div>
</div>
</div>
</Card>
<AdminAuditOverviewBar
v-model:query="query"
v-model:entity-filter="entityFilter"
v-model:from-date="fromDate"
v-model:to-date="toDate"
:stats="logStats"
:entity-options="entityOptions"
:filter-presets="filterPresets"
:applied-preset-key="appliedPresetKey"
:export-message="exportMessage"
:last-loaded-label="lastLoadedLabel"
:loading="loadingAudit"
:has-rows="auditRows.length > 0"
:active-filter-count="activeFilterCount"
@refresh="loadAuditEntries()"
@export="exportAuditCsv"
@clear="clearFilters"
@apply-preset="applyPreset"
/>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivität</h2>
<div class="mt-5 space-y-3">
<div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3">
<span class="font-semibold text-slate-900">{{ item.admin }}</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span>
</div>
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500">
Noch keine Admin-Aktivität vorhanden.
</p>
</div>
</Card>
<p v-if="auditError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ auditError }}</p>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Audit Log</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Letzte Aktionen</h2>
</div>
<div class="max-h-[520px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="entry in filteredAuditEntries" :key="entry.id" class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ entry.summary }}</p>
<p class="mt-1 text-sm text-slate-500">{{ entry.adminTwitchUserId }} · {{ entry.actionType }} · {{ entry.entityType }} {{ entry.entityId }}</p>
</div>
<span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span>
</div>
</div>
<p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Einträge gefunden.</p>
</div>
</Card>
<section class="grid gap-6 2xl:grid-cols-[360px_minmax(0,1fr)]">
<AdminAuditFocusPanel
v-model:selected-admin="selectedAdmin"
v-model:selected-action="selectedAction"
v-model:selected-entity="entityFilter"
:focus-cards="focusCards"
:admin-counts="adminCounts"
:action-counts="actionCounts"
:entity-counts="entityCounts"
/>
<AdminAuditLogList
:entries="auditRows"
:loading="loadingAudit"
:loading-more="loadingMore"
:has-more="hasMore"
:page-summary-label="pageSummaryLabel"
:empty-text="emptyStateText"
@open-detail="openAuditEntry"
@load-more="loadNextPage"
/>
</section>
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex items-center gap-3">
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<FileClock class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktionstypen</p>
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Was wurde bearbeitet?</h2>
</div>
</div>
</div>
<div class="divide-y divide-violet-50">
<div v-for="item in actionCounts" :key="item.action" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_120px] lg:items-center">
<div>
<p class="font-semibold text-slate-900">{{ item.action }}</p>
<p class="mt-1 text-sm text-slate-500">Audit-Kategorie für Team-Aktionen</p>
</div>
<span class="rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-center text-sm font-semibold text-violet-700">{{ item.count }}</span>
</div>
<p v-if="actionCounts.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Noch keine Aktionstypen vorhanden.
</p>
</div>
</Card>
<AdminAuditDetailDrawer :entry="selectedEntry" @close="closeAuditEntry" />
</div>
</template>
@@ -1,161 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircle2, LockKeyhole, ShieldAlert, Tags, Vote } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
const seasonDetail = computed(() => store.adminSeasonDetail)
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
const votingReadiness = computed(() =>
seasonDetail.value.categories.map((category) => {
const candidateCount = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
const reviewCount = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
return {
...category,
candidateCount,
reviewCount,
ready: candidateCount > 0 && reviewCount === 0 && seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
}
}),
)
const readyCount = computed(() => votingReadiness.value.filter((category) => category.ready).length)
const notReadyCategories = computed(() => votingReadiness.value.filter((category) => !category.ready))
const lockedCategories = computed(() =>
votingReadiness.value.filter((category) => category.candidateCount === 0 || category.reviewCount > 0),
)
const stats = computed(() => [
{ label: 'Stimmen gesamt', value: totalVotes.value, icon: Vote },
{ label: 'Voting-ready', value: readyCount.value, icon: CheckCircle2 },
{ label: 'Gesperrt', value: lockedCategories.value.length, icon: LockKeyhole },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, icon: Tags },
])
const votingChecklist = computed(() => [
{
label: 'Voting-Phase aktiv',
done: seasonDetail.value.currentPhase.toLowerCase().includes('voting'),
note: seasonDetail.value.currentPhase || 'Keine Phase gesetzt',
to: '/admin/settings',
},
{
label: 'Alle Kategorien haben Kandidaten',
done: votingReadiness.value.every((category) => category.candidateCount > 0) && seasonDetail.value.categories.length > 0,
note: `${notReadyCategories.value.filter((category) => category.candidateCount === 0).length} Kategorien ohne Kandidaten`,
to: '/admin/categories',
},
{
label: 'Offene Reviews niedrig',
done: seasonDetail.value.pendingNominations.length === 0,
note: `${seasonDetail.value.pendingNominations.length} offene Reviews`,
to: '/admin/reviews',
},
{
label: 'Risikohinweise geprüft',
done: store.admin.riskFlags.length === 0,
note: `${store.admin.riskFlags.length} offene Flags vor Ergebnisfreigabe`,
to: '/admin/risk',
},
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Voting"
title="Voting freigeben und absichern"
description="Diese Ansicht ist jetzt operativ: Phase, Kandidatenbasis, offene Reviews und Risiko-Flags entscheiden, ob eine Kategorie fürs Community Voting bereit ist."
:icon="Vote"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card v-for="stat in stats" :key="stat.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ stat.label }}</p>
<strong class="mt-3 block text-3xl text-violet-900">{{ stat.value.toLocaleString('de-DE') }}</strong>
</div>
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<component :is="stat.icon" class="h-5 w-5" />
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie-Check</h2>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="category in votingReadiness" :key="category.id" class="grid grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten · {{ category.reviewCount }} Reviews</p>
</div>
<span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'">
{{ category.ready ? 'bereit' : 'prüfen' }}
</span>
</div>
</div>
</Card>
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Sperrgründe</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was blockiert?</h2>
</div>
<ShieldAlert class="h-6 w-6 text-amber-500" />
</div>
<div class="mt-5 space-y-3">
<RouterLink
v-for="category in lockedCategories"
:key="category.id"
:to="category.candidateCount === 0 ? '/admin/candidates' : '/admin/reviews'"
class="block rounded-[22px] border border-amber-100 bg-amber-50/50 p-4 transition hover:bg-amber-50"
>
<p class="font-semibold text-slate-900">{{ category.name }}</p>
<p class="mt-1 text-sm text-slate-600">
{{ category.candidateCount === 0 ? 'Keine Kandidaten gepflegt.' : `${category.reviewCount} offene Reviews vor Voting-Freigabe.` }}
</p>
</RouterLink>
<p v-if="lockedCategories.length === 0" class="rounded-[22px] border border-emerald-100 bg-emerald-50/50 px-5 py-6 text-sm text-emerald-700">
Keine Kategorie ist durch Inhalt oder Review-Backlog blockiert.
</p>
</div>
</Card>
</section>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Voting Checkliste</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Vor dem Public Push</h2>
<div class="mt-5 grid gap-3 lg:grid-cols-4">
<RouterLink
v-for="item in votingChecklist"
:key="item.label"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:-translate-y-0.5 hover:bg-violet-50/50"
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/50'"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'">
{{ item.done ? 'ok' : 'prüfen' }}
</span>
</div>
</RouterLink>
</div>
</Card>
</div>
</template>
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
const {
adminError,
adminMessage,
completionPct,
deletingResultId,
query,
savingResultForCategory,
statusFilter,
statusFilters,
summaryCards,
visibleResultRows,
winnerSelections,
clearWinner,
saveWinner,
} = useAdminWinnersManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Gewinner"
description="Finale Gewinner je Kategorie setzen, aktualisieren und fuer Archiv sowie Public-Ansicht freigeben."
:icon="Trophy"
/>
<AdminSeasonToolbar />
<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">
<div class="min-w-0">
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500">{{ card.label }}</p>
<strong class="mt-2 block text-2xl text-slate-950">{{ card.value }}</strong>
<p class="mt-1 text-sm leading-5 text-slate-500">{{ card.note }}</p>
</div>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-xl border" :class="card.tone">
<component :is="card.icon" class="h-5 w-5" />
</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>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<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"
>
<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>
<select
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
class="h-12 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
>
<option value="">Bitte Gewinner waehlen</option>
<option v-for="candidate in row.candidates" :key="candidate.id" :value="`${candidate.id}`">
{{ candidate.displayName }} · {{ candidate.channelSlug }} · {{ candidate.platform }}
</option>
</select>
<div class="flex flex-wrap justify-end gap-2">
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @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>
<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>
</Card>
</div>
</template>
+333
View File
@@ -0,0 +1,333 @@
.maintenance-page .not-found__badge {
color: #7f58d7;
}
.maintenance-page .not-found__pillow-wrap {
width: 20rem;
height: 17.5rem;
margin-top: 1.2rem;
}
.maintenance-tea {
--tea-scale: 1;
position: relative;
width: 18rem;
height: 16.4rem;
display: grid;
place-items: center;
animation: teaFloat 6.2s ease-in-out infinite;
}
.maintenance-tea__steam-field {
position: absolute;
top: 0.1rem;
left: 50%;
width: 15.4rem;
height: 8.4rem;
transform: translateX(-50%);
pointer-events: none;
}
.maintenance-tea__steam {
position: absolute;
bottom: 0.9rem;
width: 3.2rem;
height: 5.8rem;
border-radius: 999px;
border-left: 2px solid rgba(160, 123, 235, 0.26);
border-top: 2px solid rgba(255, 214, 232, 0.42);
filter: drop-shadow(0 0 10px rgba(191, 157, 255, 0.22));
opacity: 0.7;
animation: teaSteam 4.8s ease-in-out infinite;
}
.maintenance-tea__steam--one {
--steam-rotate: -16deg;
left: 3.1rem;
transform: rotate(var(--steam-rotate));
}
.maintenance-tea__steam--two {
--steam-rotate: 8deg;
left: 6.5rem;
height: 6.5rem;
transform: rotate(var(--steam-rotate));
animation-delay: 0.7s;
}
.maintenance-tea__steam--three {
--steam-rotate: 18deg;
right: 3.4rem;
transform: rotate(var(--steam-rotate));
animation-delay: 1.4s;
}
.maintenance-tea__constellation {
position: absolute;
inset: 0;
overflow: visible;
}
.maintenance-tea__constellation path {
fill: none;
stroke: rgba(150, 111, 226, 0.38);
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 190;
stroke-dashoffset: 190;
animation: teaConstellationDraw 5.8s ease-in-out infinite;
}
.maintenance-tea__constellation path:nth-of-type(2) {
animation-delay: 0.35s;
}
.maintenance-tea__constellation circle {
fill: #fffaf2;
stroke: rgba(177, 139, 244, 0.46);
stroke-width: 1.6;
filter: drop-shadow(0 0 9px rgba(164, 125, 242, 0.5));
transform-origin: center;
animation: teaStarPulse 3.6s ease-in-out infinite;
}
.maintenance-tea__constellation circle:nth-of-type(2n) {
animation-delay: 0.7s;
}
.maintenance-tea__constellation circle:nth-of-type(3n) {
animation-delay: 1.2s;
}
.maintenance-tea__cloud {
position: absolute;
left: 50%;
bottom: 2.15rem;
width: 15.5rem;
height: 4.2rem;
transform: translateX(-50%);
border-radius: 999px;
background:
radial-gradient(circle at 18% 40%, rgba(255, 255, 255, 0.92) 0 2.8rem, transparent 2.9rem),
radial-gradient(circle at 42% 22%, rgba(255, 246, 255, 0.94) 0 3.2rem, transparent 3.3rem),
radial-gradient(circle at 70% 38%, rgba(255, 255, 255, 0.9) 0 2.8rem, transparent 2.9rem),
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(239, 226, 255, 0.58));
box-shadow: 0 18px 38px rgba(142, 111, 209, 0.14);
opacity: 0.9;
}
.maintenance-tea__cloud span {
position: absolute;
top: -1.15rem;
width: 3.7rem;
height: 3.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
filter: blur(0.2px);
}
.maintenance-tea__cloud span:nth-child(1) {
left: 1.4rem;
}
.maintenance-tea__cloud span:nth-child(2) {
top: -1.75rem;
left: 5.8rem;
width: 4.5rem;
height: 4.5rem;
}
.maintenance-tea__cloud span:nth-child(3) {
right: 1.8rem;
}
.maintenance-tea__saucer {
position: absolute;
bottom: 3.1rem;
left: 50%;
width: 11.6rem;
height: 2.25rem;
transform: translateX(-50%);
border-radius: 999px;
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.95) 0 38%, rgba(255, 222, 235, 0.82) 39% 66%, rgba(193, 159, 248, 0.62) 100%);
box-shadow:
0 18px 38px rgba(132, 96, 206, 0.18),
inset 0 3px 10px rgba(255, 255, 255, 0.72);
}
.maintenance-tea__cup {
position: absolute;
bottom: 4.2rem;
left: 50%;
width: 8.7rem;
height: 5.9rem;
overflow: hidden;
transform: translateX(-50%);
border-radius: 1.2rem 1.2rem 2.5rem 2.5rem;
background:
radial-gradient(circle at 27% 26%, rgba(255, 255, 255, 0.98) 0 1.25rem, transparent 1.3rem),
linear-gradient(135deg, #fff8df 0%, #ffd9e7 42%, #cdb3ff 100%);
box-shadow:
inset 14px 15px 22px rgba(255, 255, 255, 0.48),
inset -13px -16px 24px rgba(118, 85, 187, 0.13),
0 20px 44px rgba(151, 116, 221, 0.23);
}
.maintenance-tea__cup::before {
content: '';
position: absolute;
top: 0.58rem;
right: 0.9rem;
left: 0.9rem;
height: 0.85rem;
border-radius: 999px;
background: linear-gradient(90deg, rgba(139, 108, 219, 0.2), rgba(255, 255, 255, 0.62), rgba(231, 177, 62, 0.24));
}
.maintenance-tea__tea {
position: absolute;
top: 0.76rem;
right: 1.05rem;
left: 1.05rem;
height: 0.48rem;
border-radius: 999px;
background: rgba(151, 102, 220, 0.42);
box-shadow: 0 0 18px rgba(177, 139, 244, 0.38);
}
.maintenance-tea__shine {
position: absolute;
top: 1.9rem;
left: 1.55rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.48);
filter: blur(1px);
}
.maintenance-tea__label {
position: absolute;
top: 2.65rem;
left: 50%;
transform: translateX(-50%) rotate(-2deg);
color: #fffaf8;
font-family: 'Fredoka', sans-serif;
font-size: 1.18rem;
font-weight: 800;
letter-spacing: 0.02em;
text-shadow: 0 5px 16px rgba(89, 57, 152, 0.22);
text-transform: uppercase;
}
.maintenance-tea__handle {
position: absolute;
right: 3.05rem;
bottom: 5.25rem;
width: 3.2rem;
height: 3.9rem;
border: 0.75rem solid rgba(214, 187, 255, 0.82);
border-left: 0;
border-radius: 0 999px 999px 0;
box-shadow:
inset -5px 4px 12px rgba(255, 255, 255, 0.42),
0 10px 22px rgba(126, 88, 203, 0.12);
}
.maintenance-tea__spark {
position: absolute;
z-index: 3;
color: #fffdf7;
text-shadow: 0 0 18px rgba(158, 115, 237, 0.48);
animation: twinklePastel 3.6s ease-in-out infinite;
}
.maintenance-tea__spark--one {
top: 2.35rem;
left: 2.25rem;
font-size: 1.18rem;
}
.maintenance-tea__spark--two {
top: 4.1rem;
right: 1.8rem;
color: #b892ff;
font-size: 1rem;
animation-delay: 0.8s;
}
.maintenance-tea__spark--three {
bottom: 5.25rem;
left: 1.45rem;
color: #ffd7eb;
font-size: 0.9rem;
animation-delay: 1.6s;
}
.maintenance-page .not-found__primary:disabled {
cursor: progress;
opacity: 0.76;
}
@keyframes teaFloat {
0%, 100% {
transform: translateY(0) rotate(-1.4deg) scale(var(--tea-scale));
}
50% {
transform: translateY(-10px) rotate(1.2deg) scale(var(--tea-scale));
}
}
@keyframes teaSteam {
0%, 100% {
opacity: 0.36;
transform: translateY(8px) scale(0.95) rotate(var(--steam-rotate));
}
50% {
opacity: 0.9;
transform: translateY(-8px) scale(1.05) rotate(var(--steam-rotate));
}
}
@keyframes teaConstellationDraw {
0% {
opacity: 0;
stroke-dashoffset: 190;
}
18%, 68% {
opacity: 1;
stroke-dashoffset: 0;
}
100% {
opacity: 0;
stroke-dashoffset: -190;
}
}
@keyframes teaStarPulse {
0%, 100% {
opacity: 0.62;
transform: scale(0.84);
}
50% {
opacity: 1;
transform: scale(1.18);
}
}
@media (max-width: 640px) {
.maintenance-page .not-found__pillow-wrap {
width: 16.5rem;
height: 15rem;
}
.maintenance-tea {
--tea-scale: 0.86;
}
}
+275
View File
@@ -0,0 +1,275 @@
.network-scene {
position: relative;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.22), transparent 28%),
linear-gradient(180deg, #14091f 0%, #26113d 36%, #3e2266 72%, #1b0d2c 100%);
color: #fff7ff;
}
.network-scene__nebula,
.network-scene__stars,
.network-scene__orbital,
.network-scene__comet {
position: absolute;
pointer-events: none;
}
.network-scene__nebula {
border-radius: 999px;
filter: blur(56px);
mix-blend-mode: screen;
opacity: 0.92;
animation: drift 18s ease-in-out infinite;
}
.network-scene__nebula--violet {
top: -10%;
left: -6%;
width: 34rem;
height: 34rem;
background: radial-gradient(circle, rgba(141, 111, 255, 0.72) 0%, rgba(141, 111, 255, 0.08) 60%, transparent 78%);
}
.network-scene__nebula--rose {
top: 18%;
right: -10%;
width: 28rem;
height: 28rem;
background: radial-gradient(circle, rgba(255, 182, 222, 0.58) 0%, rgba(255, 182, 222, 0.05) 60%, transparent 78%);
animation-delay: -5s;
}
.network-scene__nebula--gold {
bottom: -16%;
left: 18%;
width: 30rem;
height: 24rem;
background: radial-gradient(circle, rgba(255, 213, 144, 0.35) 0%, rgba(255, 213, 144, 0.05) 54%, transparent 76%);
animation-delay: -9s;
}
.network-scene__stars {
inset: -20%;
background-repeat: repeat;
}
.network-scene__stars--far {
opacity: 0.5;
background-image:
radial-gradient(circle, rgba(255, 255, 255, 0.88) 0 1px, transparent 1.8px),
radial-gradient(circle, rgba(208, 190, 255, 0.84) 0 1px, transparent 1.8px);
background-size: 150px 150px, 240px 240px;
animation: driftStars 44s linear infinite;
}
.network-scene__stars--near {
opacity: 0.72;
background-image:
radial-gradient(circle, rgba(255, 246, 223, 0.92) 0 1.6px, transparent 2.4px),
radial-gradient(circle, rgba(255, 255, 255, 0.96) 0 1.4px, transparent 2.2px);
background-size: 112px 112px, 164px 164px;
animation: driftStarsNear 26s linear infinite;
}
.network-scene__orbital {
border: 1px solid rgba(248, 232, 255, 0.12);
border-radius: 999px;
animation: rotateRing 26s linear infinite;
}
.network-scene__orbital--one {
width: 38rem;
height: 38rem;
}
.network-scene__orbital--two {
width: 28rem;
height: 28rem;
border-style: dashed;
border-color: rgba(255, 219, 171, 0.24);
animation-direction: reverse;
animation-duration: 18s;
}
.network-scene__comet {
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 241, 213, 0.92) 60%, rgba(255, 255, 255, 1) 100%);
box-shadow: 0 0 16px rgba(255, 241, 213, 0.72);
transform: rotate(-22deg);
opacity: 0;
}
.network-scene__comet--one {
top: 18%;
right: 18%;
width: 16rem;
animation: comet 5.8s ease-out infinite;
}
.network-scene__comet--two {
top: 34%;
left: 14%;
width: 12rem;
animation: comet 6.5s ease-out infinite 1.8s;
}
.network-scene__content {
position: relative;
z-index: 1;
width: min(44rem, calc(100% - 2.5rem));
text-align: center;
}
.network-scene__signal {
position: relative;
display: grid;
place-items: center;
width: 15rem;
height: 15rem;
margin: 0 auto 1.5rem;
}
.network-scene__signal-ring {
position: absolute;
border-radius: 999px;
border: 1px solid rgba(245, 231, 255, 0.16);
}
.network-scene__signal-ring--outer {
inset: 0;
animation: rotateRing 22s linear infinite;
}
.network-scene__signal-ring--mid {
inset: 1.8rem;
border-style: dashed;
border-color: rgba(255, 214, 167, 0.28);
animation: rotateRing 12s linear infinite reverse;
}
.network-scene__signal-core {
width: 8rem;
height: 8rem;
border-radius: 999px;
display: grid;
place-items: center;
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.98), rgba(255, 239, 208, 0.94) 28%, rgba(255, 170, 176, 0.88) 58%, rgba(145, 111, 255, 0.84) 100%);
color: #fff;
box-shadow:
0 0 44px rgba(255, 227, 181, 0.66),
0 0 88px rgba(169, 134, 255, 0.42),
0 24px 80px rgba(16, 6, 27, 0.36);
animation: pulse 3.2s ease-in-out infinite;
}
.network-scene__eyebrow {
margin: 0 0 0.75rem;
font-size: 0.76rem;
letter-spacing: 0.3em;
text-transform: uppercase;
font-weight: 800;
color: #d1b6ff;
}
.network-scene__title {
margin: 0;
font-family: 'Cormorant Garamond', serif;
font-size: clamp(2.8rem, 7vw, 5rem);
line-height: 0.96;
}
.network-scene__text {
max-width: 38rem;
margin: 1rem auto 0;
color: rgba(247, 239, 255, 0.88);
font-size: 1.05rem;
line-height: 1.8;
}
.network-scene__detail {
max-width: 35rem;
margin: 1rem auto 0;
color: rgba(224, 206, 255, 0.76);
font-size: 0.95rem;
line-height: 1.7;
}
.network-scene__actions {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
justify-content: center;
margin-top: 2rem;
}
.network-scene__ghost {
min-height: 2.85rem;
padding: 0 1.2rem;
border-radius: 999px;
border: 1px solid rgba(239, 225, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: #fff7ff;
font-weight: 600;
cursor: pointer;
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.network-scene__ghost:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 232, 201, 0.34);
}
@keyframes drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0.9rem, -1rem, 0) scale(1.06); }
}
@keyframes driftStars {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-6rem, 4rem, 0); }
}
@keyframes driftStarsNear {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(5rem, 6rem, 0); }
}
@keyframes rotateRing {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes comet {
0% { opacity: 0; transform: translate3d(0, 0, 0) rotate(-22deg); }
8% { opacity: 1; }
32% { opacity: 1; transform: translate3d(-6rem, 5rem, 0) rotate(-22deg); }
100% { opacity: 0; transform: translate3d(-10rem, 8rem, 0) rotate(-22deg); }
}
@media (max-width: 640px) {
.network-scene__signal {
width: 12.5rem;
height: 12.5rem;
}
.network-scene__signal-core {
width: 6.6rem;
height: 6.6rem;
}
.network-scene__text {
font-size: 0.98rem;
}
}
+303
View File
@@ -0,0 +1,303 @@
.not-found {
position: relative;
min-height: 100vh;
overflow: hidden;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.55), transparent 30%),
linear-gradient(180deg, #fff9ff 0%, #f7efff 28%, #efe4ff 58%, #fdeef6 100%);
color: #483768;
}
.not-found__sky {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle, rgba(146, 115, 232, 0.22) 0 1.1px, transparent 1.9px),
radial-gradient(circle, rgba(255, 194, 210, 0.26) 0 1.3px, transparent 2.1px),
radial-gradient(circle, rgba(255, 235, 178, 0.4) 0 1.5px, transparent 2.4px);
background-size: 150px 150px, 220px 220px, 280px 280px;
animation: starScroll 32s linear infinite;
}
.not-found__glow {
position: absolute;
border-radius: 999px;
filter: blur(62px);
opacity: 0.8;
mix-blend-mode: multiply;
animation: floatGlow 14s ease-in-out infinite;
}
.not-found__glow--violet {
top: -6%;
left: -4%;
width: 28rem;
height: 28rem;
background: rgba(183, 153, 255, 0.56);
}
.not-found__glow--peach {
bottom: -12%;
right: 8%;
width: 30rem;
height: 24rem;
background: rgba(255, 222, 183, 0.62);
animation-delay: -4s;
}
.not-found__glow--pink {
top: 30%;
right: -8%;
width: 22rem;
height: 22rem;
background: rgba(255, 194, 224, 0.58);
animation-delay: -8s;
}
.not-found__star {
position: absolute;
color: #9b72f0;
text-shadow: 0 0 14px rgba(255, 255, 255, 0.85);
animation: twinklePastel 3.8s ease-in-out infinite;
}
.not-found__star--1 { top: 18%; left: 19%; font-size: 1.4rem; }
.not-found__star--2 { top: 24%; right: 18%; font-size: 1.9rem; animation-delay: .7s; }
.not-found__star--3 { bottom: 24%; left: 16%; font-size: 1.2rem; animation-delay: 1.4s; }
.not-found__star--4 { bottom: 18%; right: 20%; font-size: 1.5rem; animation-delay: 2s; }
.not-found__star--5 { top: 40%; left: 10%; font-size: 1rem; animation-delay: 2.7s; }
.not-found__content {
position: relative;
z-index: 1;
width: min(42rem, calc(100% - 2rem));
text-align: center;
}
.not-found__badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1rem;
border-radius: 999px;
border: 1px solid rgba(179, 147, 246, 0.28);
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 14px 34px rgba(177, 149, 224, 0.16);
color: #8b67db;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.not-found__pillow-wrap {
position: relative;
width: 18rem;
height: 17rem;
margin: 1.6rem auto 1.4rem;
display: grid;
place-items: center;
perspective: 900px;
}
.not-found__pillow-shadow {
position: absolute;
bottom: 1.45rem;
width: 11rem;
height: 2.2rem;
border-radius: 999px;
background: radial-gradient(ellipse, rgba(119, 87, 180, 0.22) 0%, rgba(119, 87, 180, 0.08) 48%, transparent 72%);
filter: blur(2px);
animation: pillowShadow 4.8s ease-in-out infinite;
}
.not-found__star-pillow {
position: relative;
width: 12.6rem;
height: 12.6rem;
display: grid;
place-items: center;
clip-path: polygon(50% 0%, 62% 31%, 96% 32%, 69% 53%, 79% 88%, 50% 68%, 21% 88%, 31% 53%, 4% 32%, 38% 31%);
background:
radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.98) 0%, rgba(255, 251, 236, 0.96) 18%, transparent 34%),
linear-gradient(135deg, #fff1c8 0%, #ffd3dc 38%, #d8b8ff 72%, #a98cf4 100%);
box-shadow:
inset 18px 20px 34px rgba(255, 255, 255, 0.42),
inset -18px -24px 38px rgba(115, 78, 190, 0.16),
0 20px 54px rgba(178, 141, 232, 0.3),
0 0 42px rgba(255, 221, 189, 0.5);
font-family: 'Fredoka', sans-serif;
color: #fffaf8;
transform: rotate(-4deg);
animation: pillowFloat 4.8s ease-in-out infinite;
}
.not-found__star-pillow::before {
content: '';
position: absolute;
inset: 0.8rem;
clip-path: inherit;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.5), transparent 58%);
opacity: 0.76;
}
.not-found__star-shine {
position: absolute;
top: 2.6rem;
left: 3.2rem;
width: 1.8rem;
height: 1.8rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.58);
filter: blur(1px);
}
.not-found__star-code {
position: relative;
z-index: 1;
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.08em;
text-shadow: 0 5px 18px rgba(105, 71, 162, 0.22);
}
.not-found__pillow-spark {
position: absolute;
z-index: 2;
color: #fffdf6;
text-shadow: 0 0 16px rgba(169, 126, 245, 0.55);
animation: twinklePastel 3.4s ease-in-out infinite;
}
.not-found__pillow-spark--one {
top: 2.2rem;
left: 2.6rem;
font-size: 1.2rem;
}
.not-found__pillow-spark--two {
top: 3.6rem;
right: 1.6rem;
color: #b892ff;
font-size: 1rem;
animation-delay: 0.8s;
}
.not-found__pillow-spark--three {
bottom: 4.2rem;
left: 1.4rem;
color: #ffd6eb;
font-size: 0.9rem;
animation-delay: 1.6s;
}
.not-found__eyebrow {
margin: 0 0 0.8rem;
color: #9c78e8;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.not-found__title {
margin: 0;
font-family: 'Cormorant Garamond', serif;
font-size: clamp(3rem, 8vw, 5.4rem);
line-height: 0.94;
color: #4d386f;
}
.not-found__text {
max-width: 34rem;
margin: 1rem auto 0;
color: #7c6a99;
font-size: 1.05rem;
line-height: 1.8;
}
.not-found__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.85rem;
margin-top: 2rem;
}
.not-found__primary,
.not-found__secondary {
min-height: 3rem;
padding: 0 1.2rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.not-found__primary {
border: none;
background: linear-gradient(135deg, #9f79f3 0%, #f0a6d5 100%);
color: white;
box-shadow: 0 18px 40px rgba(181, 143, 234, 0.24);
}
.not-found__secondary {
border: 1px solid rgba(184, 156, 240, 0.34);
background: rgba(255, 255, 255, 0.82);
color: #6e55a5;
}
.not-found__primary:hover,
.not-found__secondary:hover {
transform: translateY(-2px);
}
@keyframes starScroll {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-4rem, 5rem, 0); }
}
@keyframes floatGlow {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0.8rem, -1rem, 0) scale(1.06); }
}
@keyframes twinklePastel {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.28); }
}
@keyframes pillowFloat {
0%, 100% { transform: translateY(0) rotate(-4deg) scale(1); }
50% { transform: translateY(-12px) rotate(2deg) scale(1.03); }
}
@keyframes pillowShadow {
0%, 100% { opacity: 0.68; transform: scaleX(1); }
50% { opacity: 0.42; transform: scaleX(0.82); }
}
@media (max-width: 640px) {
.not-found__pillow-wrap {
width: 14.5rem;
height: 14rem;
}
.not-found__star-pillow {
width: 10rem;
height: 10rem;
}
.not-found__star-code {
font-size: 2.35rem;
}
.not-found__text {
font-size: 0.98rem;
}
}