Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -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>
|
||||
@@ -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 }} {{ 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 ♡ & 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user