Initial VTuber Awards implementation
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Select from 'primevue/select'
|
||||
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
const selectedSeasonId = ref<number | null>(null)
|
||||
const seasonSaving = ref(false)
|
||||
const categorySaving = ref<number | 'new' | null>(null)
|
||||
const candidateSaving = ref<number | 'new' | null>(null)
|
||||
const reviewSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const seasonForm = reactive({
|
||||
currentPhase: '',
|
||||
isCurrent: false,
|
||||
})
|
||||
|
||||
const newCategoryForm = reactive({
|
||||
groupName: '',
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
sortOrder: 1,
|
||||
maxNomineesPerUser: 3,
|
||||
})
|
||||
|
||||
const newCandidateForm = reactive({
|
||||
categoryId: 0,
|
||||
displayName: '',
|
||||
channelSlug: '',
|
||||
platform: 'Twitch',
|
||||
})
|
||||
|
||||
const editForms = reactive<Record<number, {
|
||||
groupName: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}>>({})
|
||||
|
||||
const candidateForms = reactive<Record<number, {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}>>({})
|
||||
|
||||
const reviewForms = reactive<Record<number, {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}>>({})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.isAdmin) return
|
||||
await store.loadAdmin()
|
||||
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
|
||||
})
|
||||
|
||||
const metrics = computed(() => store.admin.metrics)
|
||||
const activities = computed(() => store.admin.activities)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const seasons = computed(() => store.adminSeasons)
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories.map((category) => ({
|
||||
label: `${category.groupName} · ${category.name}`,
|
||||
value: category.id,
|
||||
})),
|
||||
)
|
||||
|
||||
watch(selectedSeasonId, async (seasonId) => {
|
||||
if (!seasonId) return
|
||||
await store.loadAdminSeasonDetail(seasonId)
|
||||
})
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
seasonForm.currentPhase = detail.currentPhase
|
||||
seasonForm.isCurrent = detail.isCurrent
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of detail.candidates) {
|
||||
candidateForms[candidate.id] = {
|
||||
categoryId: candidate.categoryId,
|
||||
displayName: candidate.displayName,
|
||||
channelSlug: candidate.channelSlug,
|
||||
platform: candidate.platform,
|
||||
}
|
||||
}
|
||||
|
||||
for (const nomination of detail.pendingNominations) {
|
||||
reviewForms[nomination.id] = {
|
||||
displayName: nomination.candidateText,
|
||||
channelSlug: '',
|
||||
platform: 'Twitch',
|
||||
}
|
||||
}
|
||||
|
||||
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const seasonOptions = computed(() =>
|
||||
seasons.value.map((season) => ({
|
||||
label: `${season.year} · ${season.name}`,
|
||||
value: season.id,
|
||||
})),
|
||||
)
|
||||
|
||||
async function saveSeason() {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
seasonSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSeason(selectedSeasonId.value, {
|
||||
currentPhase: seasonForm.currentPhase,
|
||||
isCurrent: seasonForm.isCurrent,
|
||||
})
|
||||
await store.loadAdminSeasonDetail(selectedSeasonId.value)
|
||||
adminMessage.value = 'Season-Einstellungen gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
seasonSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCategory(categoryId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
categorySaving.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 {
|
||||
categorySaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
categorySaving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||
adminMessage.value = 'Neue 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 {
|
||||
categorySaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCandidate(candidateId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
|
||||
candidateSaving.value = candidateId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
|
||||
adminMessage.value = 'Kandidat gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
candidateSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCandidate() {
|
||||
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
|
||||
|
||||
candidateSaving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
|
||||
adminMessage.value = 'Kandidat angelegt.'
|
||||
newCandidateForm.displayName = ''
|
||||
newCandidateForm.channelSlug = ''
|
||||
newCandidateForm.platform = 'Twitch'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
candidateSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
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 uebernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen 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 Queue entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<Card v-if="!authStore.isAdmin" class="p-8">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
|
||||
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
|
||||
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
|
||||
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-4">
|
||||
<Card
|
||||
v-for="metric in metrics"
|
||||
:key="metric.label"
|
||||
class="p-7"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
|
||||
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-sm font-semibold text-slate-600">Season</label>
|
||||
<Select
|
||||
v-model="selectedSeasonId"
|
||||
:options="seasonOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-sm font-semibold text-slate-600">Phase</label>
|
||||
<input
|
||||
v-model="seasonForm.currentPhase"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
|
||||
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
|
||||
Diese Season ist die aktuelle Public Season
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
||||
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
|
||||
</Button>
|
||||
<span class="text-sm text-slate-500">
|
||||
{{ seasonDetail.year }} · {{ seasonDetail.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
|
||||
<DataTable :value="topCategories" class="mt-6" striped-rows>
|
||||
<Column field="category" header="Kategorie" />
|
||||
<Column field="votes" header="Votes">
|
||||
<template #body="{ data }">
|
||||
{{ Number(data.votes).toLocaleString('de-DE') }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.categories.length }} Kategorien
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="category in seasonDetail.categories"
|
||||
:key="category.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
|
||||
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
|
||||
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
|
||||
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
{{ category.candidateCount }} Kandidaten in dieser Kategorie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="editForms[category.id].description"
|
||||
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Beschreibung"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
|
||||
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
|
||||
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
|
||||
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
|
||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
|
||||
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
|
||||
</div>
|
||||
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.candidates.length }} Kandidaten
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="candidate in seasonDetail.candidates"
|
||||
:key="candidate.id"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
|
||||
>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Select
|
||||
v-model="candidateForms[candidate.id].categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
|
||||
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
|
||||
<div class="mt-6 space-y-4">
|
||||
<Select
|
||||
v-model="newCandidateForm.categoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
|
||||
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
|
||||
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
|
||||
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
|
||||
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.label"
|
||||
class="rounded-[26px] 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-1 text-sm text-slate-500">{{ activity.age }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ seasonDetail.pendingNominations.length }} offen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="nomination in seasonDetail.pendingNominations"
|
||||
:key="nomination.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">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
ID {{ nomination.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-3">
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].displayName"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Display Name"
|
||||
/>
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].channelSlug"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="@channel"
|
||||
/>
|
||||
<input
|
||||
v-model="reviewForms[nomination.id].platform"
|
||||
type="text"
|
||||
class="rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Platform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import Tag from 'primevue/tag'
|
||||
import { ArrowRight, Sparkles, Star, Trophy, WandSparkles } from '@lucide/vue'
|
||||
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import hostVisual from '../assets/collector-editorial-reference.png'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
void store.loadHomeData()
|
||||
})
|
||||
|
||||
const heroYear = computed(() => store.overview.year)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-20 pb-16">
|
||||
<section class="grid gap-10 lg:grid-cols-[0.82fr_1.18fr] lg:items-start">
|
||||
<div class="space-y-10 pt-3">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Die groesste Community-Auszeichnung</p>
|
||||
<h1 class="max-w-[8ch] font-[Cormorant_Garamond] text-6xl leading-[0.88] text-violet-800 sm:text-7xl xl:text-[6.6rem]">
|
||||
VTuber Star Awards
|
||||
</h1>
|
||||
<p class="text-2xl font-medium italic tracking-wide text-violet-500">
|
||||
Presented by Jayuhime
|
||||
</p>
|
||||
<p class="max-w-lg text-lg leading-8 text-slate-600">
|
||||
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres.
|
||||
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<RouterLink to="/nominations">
|
||||
<Button size="lg">Jetzt nominieren</Button>
|
||||
</RouterLink>
|
||||
<RouterLink to="/voting">
|
||||
<Button variant="secondary" size="lg">Jetzt voten</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Card class="min-h-[210px] p-7">
|
||||
<div class="flex items-center gap-3 text-violet-600">
|
||||
<Sparkles class="h-5 w-5 text-amber-500" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Community powered</span>
|
||||
</div>
|
||||
<p class="mt-5 text-sm leading-7 text-slate-600">
|
||||
Twitch Login only, keine Konto-Huerde, editierbare Votes und Nominierungen bis zur Deadline.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card class="min-h-[210px] p-7">
|
||||
<div class="flex items-center gap-3 text-violet-600">
|
||||
<WandSparkles class="h-5 w-5 text-amber-500" />
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Team verwaltet pro Jahr</span>
|
||||
</div>
|
||||
<p class="mt-5 text-sm leading-7 text-slate-600">
|
||||
Kategorien und Unterkategorien werden im Admin-Bereich je Season gepflegt und freigeschaltet.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Aktuelle Phase</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">
|
||||
{{ store.overview.currentPhase }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-md text-slate-600">
|
||||
Login bleibt leichtgewichtig: Twitch only, kein separates Community-Konto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Session Status</p>
|
||||
<p class="mt-2 font-semibold text-violet-800">
|
||||
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
|
||||
</p>
|
||||
<p class="mt-2 leading-7 text-slate-600">
|
||||
{{ authStore.isLoggedIn ? 'Nominierung und Voting sind jetzt direkt freigeschaltet.' : 'Bitte oben im Header einloggen, um Nominierung, Voting oder Admin zu nutzen.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
|
||||
<strong class="block text-2xl text-violet-800">41</strong>
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Tage</span>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
|
||||
<strong class="block text-2xl text-violet-800">08</strong>
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Std</span>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
|
||||
<strong class="block text-2xl text-violet-800">24</strong>
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Min</span>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
|
||||
<strong class="block text-2xl text-violet-800">16</strong>
|
||||
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Sek</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden p-0">
|
||||
<div class="relative min-h-[760px] bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.82),transparent_26%),linear-gradient(160deg,rgba(224,214,255,0.72),rgba(255,240,217,0.68))]">
|
||||
<div class="absolute inset-0 bg-[radial-gradient(circle_at_75%_15%,rgba(255,255,255,0.82),transparent_25%)]" />
|
||||
<div class="absolute left-10 top-10 rounded-full border border-white/60 bg-white/60 px-4 py-1 text-xs uppercase tracking-[0.3em] text-violet-600">
|
||||
Presented by Jayuhime
|
||||
</div>
|
||||
<img
|
||||
:src="hostVisual"
|
||||
alt="Jayuhime Host Keyvisual"
|
||||
class="absolute inset-0 h-full w-full object-cover object-center"
|
||||
/>
|
||||
<div class="absolute inset-y-0 right-0 flex w-full max-w-[340px] flex-col justify-between border-l border-white/40 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.38))] p-7 backdrop-blur md:p-8">
|
||||
<div class="space-y-8">
|
||||
<div class="rounded-[28px] border border-white/50 bg-white/30 p-5">
|
||||
<div class="flex items-center gap-3 text-violet-700">
|
||||
<Star class="h-5 w-5 text-amber-500" />
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.2em]">Jayuhime · Host of the Show</span>
|
||||
</div>
|
||||
<p class="mt-4 text-sm leading-7 text-slate-700">
|
||||
Editoriales Hero-Panel mit klarer Host-Praesenz, aber mehr White Space und weniger competing elements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tag value="Collector Editorial" severity="warn" class="self-start" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Show Date</p>
|
||||
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">24. Jan 2026</p>
|
||||
</div>
|
||||
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p>
|
||||
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Community only</p>
|
||||
</div>
|
||||
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p>
|
||||
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Twitch</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-5 lg:grid-cols-4">
|
||||
<Card
|
||||
v-for="item in store.overview.timeline"
|
||||
:key="item.key"
|
||||
class="p-7"
|
||||
>
|
||||
<Tag :value="item.state === 'active' ? 'live' : item.state" severity="secondary" class="mb-4" />
|
||||
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ item.title }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ item.startsAt }} - {{ item.endsAt }}</p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-5 lg:grid-cols-3">
|
||||
<Card class="min-h-[260px] p-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<Trophy class="h-5 w-5 text-amber-500" />
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">How it works</p>
|
||||
</div>
|
||||
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Nominate, vote, celebrate.</h2>
|
||||
<p class="mt-3 text-slate-600">
|
||||
Die Plattform trennt bewusst zwischen showhafter Startseite und ruhigen Produktflows. So bleibt der Einstieg emotional, waehrend die Interaktion klar bleibt.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card class="min-h-[260px] p-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<Sparkles class="h-5 w-5 text-amber-500" />
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Rules</p>
|
||||
</div>
|
||||
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Moderater Abuse-Schutz</h2>
|
||||
<p class="mt-3 text-slate-600">
|
||||
Rate limits, serverseitige Pruefung und Risikoflags laufen im Hintergrund. Die User-Huerde bleibt niedrig, der operative Blick landet im Admin.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card class="min-h-[260px] p-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<WandSparkles class="h-5 w-5 text-amber-500" />
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
|
||||
</div>
|
||||
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Season-first Management</h2>
|
||||
<p class="mt-3 text-slate-600">
|
||||
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als saisonale Inhalte gedacht, nicht als harte statische App-Texte.
|
||||
</p>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Featured Kategorien</p>
|
||||
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">Team-gesteuerte Awards fuer {{ heroYear }}</h2>
|
||||
</div>
|
||||
<span class="text-sm text-slate-500">
|
||||
API-Modus:
|
||||
<strong class="text-violet-700">{{ store.apiMode }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<Card
|
||||
v-for="category in store.overview.featuredCategories"
|
||||
:key="category.id"
|
||||
class="p-6"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category.groupName }}</p>
|
||||
<h3 class="mt-3 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ category.name }}</h3>
|
||||
<p class="mt-3 text-slate-600">{{ category.description }}</p>
|
||||
<div class="mt-6 flex items-center justify-between text-sm text-slate-500">
|
||||
<span>Max. {{ category.maxNomineesPerUser }} Nominierungen</span>
|
||||
<ArrowRight class="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card class="p-7">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierung</p>
|
||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Bis zu drei Favoriten, direkt validiert</h2>
|
||||
<ul class="mt-5 space-y-3 text-slate-600">
|
||||
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</li>
|
||||
<li>Regeln werden direkt im Formular sichtbar gemacht.</li>
|
||||
<li>Freitext-Ideen und Alias-Faelle gehen spaeter in die Review Queue.</li>
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<RouterLink to="/nominations">
|
||||
<Button>Zur Nominierungsansicht</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
|
||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Ein Kandidat pro Kategorie, bis zur Deadline editierbar</h2>
|
||||
<ul class="mt-5 space-y-3 text-slate-600">
|
||||
<li>Nur eine Stimme pro Kategorie.</li>
|
||||
<li>Videos, Clips und spaetere Detail-Previews koennen direkt im Flow eingebettet werden.</li>
|
||||
<li>Die Ballot-Logik lebt im Backend ueber VoteBallot und VoteEntry.</li>
|
||||
</ul>
|
||||
<div class="mt-6">
|
||||
<RouterLink to="/voting">
|
||||
<Button variant="secondary">Zur Voting-Ansicht</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<Card class="p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinner Archiv</p>
|
||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Vergangene Seasons sichtbar machen</h2>
|
||||
<p class="mt-4 text-slate-600">
|
||||
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
|
||||
</p>
|
||||
<div class="mt-6 space-y-3">
|
||||
<div
|
||||
v-for="entry in store.overview.winnersPreview"
|
||||
:key="`${entry.year}-${entry.category}`"
|
||||
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
|
||||
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
|
||||
</div>
|
||||
<Tag :value="entry.year.toString()" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p>
|
||||
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2>
|
||||
<Accordion value="0" class="mt-6">
|
||||
<AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)">
|
||||
<AccordionHeader>{{ item.question }}</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<p class="leading-7 text-slate-600">{{ item.answer }}</p>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.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),
|
||||
)
|
||||
|
||||
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 = `${response.saved} Nominierungen fuer ${response.category} gespeichert.`
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierungs-Flow</p>
|
||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Kategorien waehlen, Regeln live pruefen</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Nur Twitch Login, kein separates Konto. Das Team pflegt Kategorien pro Jahr, waehrend die UI sofort Limits, Dubletten und editierbare Entwuerfe abbildet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]">
|
||||
<Card class="p-7">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Regeln</p>
|
||||
<ul class="mt-5 space-y-4 text-slate-600">
|
||||
<li>Pro Kategorie nur eine Nominierung derselben Person.</li>
|
||||
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li>
|
||||
<li>Freitext-Ideen landen spaeter in der Review Queue.</li>
|
||||
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div class="space-y-5">
|
||||
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
|
||||
Bitte zuerst ueber den Header mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
|
||||
</p>
|
||||
|
||||
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
|
||||
<Select
|
||||
v-model="selectedCategoryId"
|
||||
:options="categories"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<div v-if="selectedCategory" class="rounded-[28px] bg-violet-50/70 p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ selectedCategory.groupName }}</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
|
||||
<p class="mt-2 text-slate-600">{{ selectedCategory.description }}</p>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-[28px] border border-violet-100 bg-white/70 p-5">
|
||||
<label class="text-sm font-semibold text-slate-600">Neuen Namen hinzufuegen</label>
|
||||
<input
|
||||
v-model="nomineeName"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
|
||||
placeholder="z. B. Shiro Ch."
|
||||
/>
|
||||
<Button @click="addNominee">Nominierung hinzufuegen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3>
|
||||
<div
|
||||
v-for="name in nominees"
|
||||
:key="name"
|
||||
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5"
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{ name }}</p>
|
||||
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</p>
|
||||
</div>
|
||||
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
|
||||
</div>
|
||||
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600">
|
||||
Live-Status: {{ nominees.length }}/3 Slots belegt.
|
||||
</p>
|
||||
|
||||
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">
|
||||
{{ submitMessage }}
|
||||
</p>
|
||||
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">
|
||||
{{ submitError }}
|
||||
</p>
|
||||
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination">
|
||||
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
import RadioButton from 'primevue/radiobutton'
|
||||
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.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],
|
||||
)
|
||||
|
||||
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 = `Ballot #${response.ballotId} mit ${response.entries} Eintrag gespeichert.`
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : 'Vote konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
|
||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Ein ruhiger, schneller Community-Voting-Flow</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Der V2-Flow priorisiert geringe Reibung: Twitch Login, ein Kandidat pro Kategorie, spaeter editierbar bis zur Deadline und klarer Review-Screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card class="p-7">
|
||||
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
|
||||
<div class="space-y-5">
|
||||
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
|
||||
Bitte zuerst ueber den Header mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
|
||||
<Select
|
||||
v-model="selectedCategoryId"
|
||||
:options="categoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category?.groupName }}</p>
|
||||
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">{{ category?.name }}</h2>
|
||||
<p class="text-slate-600">{{ category?.description }}</p>
|
||||
<div class="rounded-[28px] bg-violet-50/70 p-6 text-sm leading-7 text-slate-600">
|
||||
Nur eine Stimme pro Kategorie. Videos/Clips koennen spaeter direkt auf Karten oder Detailmodals referenziert werden.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label
|
||||
v-for="candidate in category?.candidates ?? []"
|
||||
:key="candidate.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5 transition hover:border-violet-300 hover:bg-white"
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
|
||||
</div>
|
||||
<RadioButton
|
||||
v-model="selectedCandidateId"
|
||||
:input-id="`candidate-${candidate.id}`"
|
||||
:name="category?.name"
|
||||
:value="candidate.id"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-7 flex flex-wrap items-center justify-between gap-4 rounded-[28px] bg-violet-50/60 px-6 py-5">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-slate-600">
|
||||
Auswahl:
|
||||
<strong class="text-violet-700">
|
||||
{{ category?.candidates.find((candidate) => candidate.id === selectedCandidateId)?.displayName ?? 'Noch keine Stimme abgegeben' }}
|
||||
</strong>
|
||||
</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 :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
|
||||
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import Button from '../components/ui/Button.vue'
|
||||
import Card from '../components/ui/Card.vue'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const years = [2025, 2024, 2023, 2022]
|
||||
const activeYear = ref(2025)
|
||||
|
||||
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-10 pb-14">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p>
|
||||
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Seasons, Gewinner und Show-Historie</h1>
|
||||
<p class="max-w-3xl text-lg leading-8 text-slate-600">
|
||||
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-for="year in years"
|
||||
:key="year"
|
||||
:variant="activeYear === year ? 'default' : 'ghost'"
|
||||
@click="selectYear(year)"
|
||||
>
|
||||
{{ year }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 lg:grid-cols-3">
|
||||
<Card
|
||||
v-for="item in store.archive.items"
|
||||
:key="`${store.archive.year}-${item.category}`"
|
||||
class="p-7"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ store.archive.year }}</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ item.category }}</h2>
|
||||
<p class="mt-4 text-lg font-semibold text-slate-800">{{ item.winnerName }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ item.winnerSlug }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user