Align backend with frontend, make clips end-to-end, polish admin
Backend - Add ClipSubmission entity + table (runtime bootstrapper) and POST /api/public/clips (server-derives platform from the link) - Surface clip submissions in the admin season detail - Add DELETE candidate/category/clip endpoints with audit entries Frontend - Clips admin: real moderation view (list, open link, delete) instead of placeholder; wired clipSubmissions through types/api/store - Categories admin: add delete with confirm modal (matches Candidates) - Voting ranking bar uses the unified purple gradient - Fix ASCII transliterations to proper German umlauts across admin - Remove orphan AdminView 2.vue Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@ const yearStats = computed(() => [
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktuelles Award-Jahr</p>
|
||||
<h2 class="truncate text-xl font-semibold text-slate-900">
|
||||
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswaehlen' }}
|
||||
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswählen' }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ const yearStats = computed(() => [
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold" :class="currentSeason.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" />
|
||||
{{ currentSeason.isCurrent ? 'Oeffentlich sichtbar' : 'Nicht oeffentlich' }}
|
||||
{{ currentSeason.isCurrent ? 'Öffentlich sichtbar' : 'Nicht öffentlich' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,8 @@ export const api = {
|
||||
sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`),
|
||||
deleteAdminCategory: (categoryId: number) =>
|
||||
sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`),
|
||||
deleteAdminClip: (clipId: number) =>
|
||||
sendDelete<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`),
|
||||
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
|
||||
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
|
||||
`/api/admin/nominations/${nominationId}/approve`,
|
||||
|
||||
@@ -179,6 +179,19 @@ const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
|
||||
createdAt: '2026-06-17T08:00:00Z',
|
||||
},
|
||||
],
|
||||
clipSubmissions: [
|
||||
{
|
||||
id: 1,
|
||||
categoryId: 1,
|
||||
submittedByTwitchId: 'demo_user',
|
||||
clipUrl: 'https://clips.twitch.tv/DemoClip',
|
||||
title: 'Epischer Clutch im Finale',
|
||||
creator: 'Hoshimi Miyu',
|
||||
platform: 'Twitch',
|
||||
status: 'pending',
|
||||
createdAt: '2026-06-17T09:10:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const emptyAdmin: AdminDashboardResponse = {
|
||||
@@ -200,6 +213,7 @@ const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
|
||||
categories: [],
|
||||
candidates: [],
|
||||
pendingNominations: [],
|
||||
clipSubmissions: [],
|
||||
}
|
||||
|
||||
export const useAwardsStore = defineStore('awards', {
|
||||
@@ -317,6 +331,11 @@ export const useAwardsStore = defineStore('awards', {
|
||||
await this.loadAdminSeasonDetail(seasonId)
|
||||
return result
|
||||
},
|
||||
async deleteAdminClip(clipId: number, seasonId: number) {
|
||||
const result = await api.deleteAdminClip(clipId)
|
||||
await this.loadAdminSeasonDetail(seasonId)
|
||||
return result
|
||||
},
|
||||
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
|
||||
const result = await api.approveAdminNomination(nominationId, payload)
|
||||
await this.loadAdminSeasonDetail(seasonId)
|
||||
|
||||
@@ -159,6 +159,18 @@ export interface AdminNominationReviewItem {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminClipSubmissionItem {
|
||||
id: number
|
||||
categoryId: number | null
|
||||
submittedByTwitchId: string
|
||||
clipUrl: string
|
||||
title: string
|
||||
creator: string
|
||||
platform: string
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonDetailResponse {
|
||||
id: number
|
||||
year: number
|
||||
@@ -168,6 +180,7 @@ export interface AdminSeasonDetailResponse {
|
||||
categories: AdminCategoryItem[]
|
||||
candidates: AdminCandidateItem[]
|
||||
pendingNominations: AdminNominationReviewItem[]
|
||||
clipSubmissions: AdminClipSubmissionItem[]
|
||||
}
|
||||
|
||||
export interface CreateNominationPayload {
|
||||
|
||||
@@ -37,7 +37,7 @@ const insights = computed(() => {
|
||||
{
|
||||
label: 'Votes pro Kandidat',
|
||||
value: votesPerCandidate,
|
||||
note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.',
|
||||
note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.',
|
||||
},
|
||||
{
|
||||
label: 'Leere Kategorien',
|
||||
@@ -58,7 +58,7 @@ const insights = computed(() => {
|
||||
<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, Luecken oder Backlog entstehen."
|
||||
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Lücken oder Backlog entstehen."
|
||||
:icon="BarChart3"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Layers3, PlusCircle, Search, Tags } from '@lucide/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 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)
|
||||
@@ -71,7 +73,7 @@ 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: 'Duenn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).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(
|
||||
@@ -150,6 +152,24 @@ function slugify(value: string) {
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -157,7 +177,7 @@ function fillNewSlug() {
|
||||
<AdminPageHeader
|
||||
eyebrow="Kategorien"
|
||||
title="Award-Struktur pflegen"
|
||||
description="Eine kompakte Arbeitsansicht fuer viele Kategorien: links filtern und auswaehlen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
|
||||
description="Eine kompakte Arbeitsansicht für viele Kategorien: links filtern und auswählen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
|
||||
:icon="Tags"
|
||||
/>
|
||||
|
||||
@@ -264,9 +284,12 @@ function fillNewSlug() {
|
||||
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
|
||||
<div class="mt-5 flex justify-end">
|
||||
<div class="mt-5 flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" class="gap-2 border border-rose-200 text-rose-600 hover:bg-rose-50" @click="categoryToDelete = selectedCategory">
|
||||
<Trash2 class="h-4 w-4" /> Löschen
|
||||
</Button>
|
||||
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
|
||||
{{ saving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }}
|
||||
{{ saving === selectedCategory.id ? 'Speichert …' : 'Kategorie speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -305,5 +328,23 @@ function fillNewSlug() {
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = 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">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden aus diesem
|
||||
Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,57 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ExternalLink, Film, Search, Tags, Users } from '@lucide/vue'
|
||||
import { ExternalLink, Film, PlayCircle, Search, Tags, Trash2, TriangleAlert, Users } from '@lucide/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 Modal from '../../components/ui/Modal.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminClipSubmissionItem } from '../../types/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const query = ref('')
|
||||
const deleting = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const clipCategories = computed(() =>
|
||||
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')),
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const categoryName = computed(() =>
|
||||
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])),
|
||||
)
|
||||
const clipCategoryIds = computed(() => new Set(clipCategories.value.map((category) => category.id)))
|
||||
const clipCandidates = computed(() => {
|
||||
|
||||
const clips = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return seasonDetail.value.candidates
|
||||
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId))
|
||||
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
|
||||
if (!search) return seasonDetail.value.clipSubmissions
|
||||
return seasonDetail.value.clipSubmissions.filter((clip) =>
|
||||
[clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search),
|
||||
)
|
||||
})
|
||||
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
|
||||
const clipReviewCount = computed(() => seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length)
|
||||
const clipReadiness = computed(() => [
|
||||
{
|
||||
label: 'Clip-Kategorie existiert',
|
||||
done: clipCategories.value.length > 0,
|
||||
note: clipCategories.value.length > 0 ? `${clipCategories.value.length} Clip-Kategorien gefunden.` : 'Lege mindestens eine Clip-Kategorie an.',
|
||||
},
|
||||
{
|
||||
label: 'Kandidaten vorhanden',
|
||||
done: clipCandidates.value.length > 0,
|
||||
note: clipCandidates.value.length > 0 ? `${clipCandidates.value.length} Clip-Kandidaten gepflegt.` : 'Noch keine Clip-Kandidaten vorhanden.',
|
||||
},
|
||||
{
|
||||
label: 'Review-Restbestand',
|
||||
done: clipReviewCount.value === 0,
|
||||
note: clipReviewCount.value === 0 ? 'Keine offenen Clip-Reviews.' : `${clipReviewCount.value} Clip-Reviews offen.`,
|
||||
},
|
||||
])
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
|
||||
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
|
||||
{ label: 'Offene Reviews', value: clipReviewCount.value, icon: Film },
|
||||
{ label: 'Einreichungen', value: seasonDetail.value.clipSubmissions.length, icon: Film },
|
||||
{ label: 'Offen', value: seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
|
||||
{ label: 'Clip-Kategorien', value: seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length, icon: Tags },
|
||||
])
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Clips"
|
||||
title="Clip-Kategorien im Blick behalten"
|
||||
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an."
|
||||
title="Clip-Einreichungen moderieren"
|
||||
description="Alle von der Community eingereichten Clips laufen hier auf. Sieh sie dir an, prüfe die Links und entferne Spam oder Duplikate."
|
||||
:icon="Film"
|
||||
/>
|
||||
|
||||
@@ -71,50 +84,77 @@ const stats = computed(() => [
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<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">Workflow</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2>
|
||||
<div class="mt-5 space-y-3">
|
||||
<div
|
||||
v-for="item in clipReadiness"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border p-4"
|
||||
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'"
|
||||
>
|
||||
<p class="font-semibold text-slate-900">{{ item.label }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">{{ item.note }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
|
||||
<p class="font-semibold text-amber-800">Naechster Backend-Ausbau</p>
|
||||
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<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="Titel, Creator, Plattform oder User suchen …"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 p-5">
|
||||
<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="Clip-Kandidat, Handle oder Plattform suchen" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div v-for="candidate in clipCandidates" :key="candidate.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-slate-900">{{ candidate.displayName }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }} · {{ candidateCategory[candidate.categoryId] }}</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
Clip-Link spaeter
|
||||
</span>
|
||||
<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 class="divide-y divide-violet-50">
|
||||
<div v-for="clip in clips" :key="clip.id" class="flex flex-col gap-3 px-5 py-4 lg:flex-row lg:items-center lg:gap-4">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
|
||||
<Film class="h-5 w-5" />
|
||||
</div>
|
||||
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an.
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-semibold text-slate-900">{{ clip.title || 'Ohne Titel' }}</p>
|
||||
<p class="truncate text-sm text-slate-500">
|
||||
{{ clip.creator || 'Unbekannt' }}
|
||||
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
|
||||
· von {{ clip.submittedByTwitchId }}
|
||||
</p>
|
||||
</div>
|
||||
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
|
||||
<a
|
||||
:href="clip.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
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>
|
||||
|
||||
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
|
||||
<Users class="mx-auto h-6 w-6 text-violet-300" />
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{ seasonDetail.clipSubmissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal :open="!!clipToDelete" title="Clip entfernen?" @close="clipToDelete = 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">
|
||||
Die Einreichung „<strong class="text-slate-800">{{ clipToDelete?.title || clipToDelete?.clipUrl }}</strong>" wird entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="clipToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? 'Entfernt …' : 'Entfernen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,7 +23,7 @@ const metricToneMap = {
|
||||
icon: BarChart3,
|
||||
trend: 8.7,
|
||||
sparkline: [54, 57, 63, 66, 72, 76, 81],
|
||||
context: 'Voting-Aktivitaet stabil positiv',
|
||||
context: 'Voting-Aktivität stabil positiv',
|
||||
},
|
||||
Kategorien: {
|
||||
icon: Tags,
|
||||
@@ -67,7 +67,7 @@ const yearTotals = computed(() => [
|
||||
{
|
||||
label: 'Kandidaten',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
note: 'fuer Voting und Archiv gepflegt',
|
||||
note: 'für Voting und Archiv gepflegt',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
@@ -99,10 +99,10 @@ const priorityActions = computed(() => [
|
||||
tone: 'violet',
|
||||
},
|
||||
{
|
||||
label: 'Risiko pruefen',
|
||||
label: 'Risiko prüfen',
|
||||
value: store.admin.riskFlags.length,
|
||||
to: '/admin/risk',
|
||||
hint: 'Auffaellige Muster brauchen Sichtung',
|
||||
hint: 'Auffällige Muster brauchen Sichtung',
|
||||
icon: ShieldAlert,
|
||||
tone: 'rose',
|
||||
},
|
||||
@@ -118,7 +118,7 @@ const priorityActions = computed(() => [
|
||||
label: 'Kandidatenbasis',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
to: '/admin/candidates',
|
||||
hint: 'Kandidaten und Plattformen schnell pruefen',
|
||||
hint: 'Kandidaten und Plattformen schnell prüfen',
|
||||
icon: Users,
|
||||
tone: 'emerald',
|
||||
},
|
||||
@@ -137,7 +137,7 @@ const operationChecks = computed(() => {
|
||||
value: categoriesWithoutCandidates.length,
|
||||
to: '/admin/categories',
|
||||
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
|
||||
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt pruefen.',
|
||||
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
|
||||
},
|
||||
{
|
||||
label: 'Review-Backlog verteilt',
|
||||
@@ -151,7 +151,7 @@ const operationChecks = computed(() => {
|
||||
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 pruefen.',
|
||||
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -162,7 +162,7 @@ const operationChecks = computed(() => {
|
||||
<AdminPageHeader
|
||||
eyebrow="Dashboard"
|
||||
title="Was braucht gerade Aufmerksamkeit?"
|
||||
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebuendelt, damit du schneller entscheiden kannst, was als Naechstes drankommt."
|
||||
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebündelt, damit du schneller entscheiden kannst, was als Nächstes drankommt."
|
||||
:icon="LayoutDashboard"
|
||||
/>
|
||||
|
||||
@@ -174,11 +174,11 @@ const operationChecks = computed(() => {
|
||||
<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, waehrend der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert.
|
||||
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% Gesamtaktivitaet
|
||||
+9.8% Gesamtaktivität
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,10 +354,10 @@ const operationChecks = computed(() => {
|
||||
<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">Aktivitaeten</p>
|
||||
<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 fuer den schnellen Blick.</p>
|
||||
<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">
|
||||
@@ -370,7 +370,7 @@ const operationChecks = computed(() => {
|
||||
<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-Aktivitaeten vorhanden.
|
||||
Noch keine aktuellen Audit-Aktivitäten vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -39,14 +39,14 @@ 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 pruefen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).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: 'Kontrolle',
|
||||
items: [
|
||||
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Faelle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags pruefen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
|
||||
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
|
||||
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags prüfen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
|
||||
{ label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
|
||||
],
|
||||
},
|
||||
@@ -119,7 +119,7 @@ onMounted(async () => {
|
||||
<Card class="p-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p>
|
||||
<p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswaehlen.' }}</p>
|
||||
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswählen.' }}</p>
|
||||
<div class="mt-3 grid grid-cols-3 gap-1.5">
|
||||
<div
|
||||
v-for="item in seasonSummary"
|
||||
|
||||
@@ -150,7 +150,7 @@ const statusFilters = computed(() => [
|
||||
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
|
||||
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
erst Kandidatenbasis klaeren
|
||||
erst Kandidatenbasis klären
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,9 +100,9 @@ async function approveNomination(nominationId: number) {
|
||||
|
||||
try {
|
||||
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
|
||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
|
||||
adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
@@ -149,7 +149,7 @@ function setPlatform(platform: string) {
|
||||
<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 fuer viele Freitext-Faelle. Waehle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
|
||||
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]">
|
||||
@@ -229,10 +229,10 @@ function setPlatform(platform: string) {
|
||||
</button>
|
||||
|
||||
<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-Faelle im aktuell gewaehlten Award-Jahr.
|
||||
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-Faelle passen zum aktuellen Filter.
|
||||
Keine Review-Fälle passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -251,7 +251,7 @@ function setPlatform(platform: string) {
|
||||
</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 uebernehmen</p>
|
||||
<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>
|
||||
@@ -294,7 +294,7 @@ function setPlatform(platform: string) {
|
||||
</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">
|
||||
Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
|
||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -305,7 +305,7 @@ function setPlatform(platform: string) {
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)">
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
|
||||
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,8 +69,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Risiko & Audit"
|
||||
title="Auffaellige Muster und Admin-Aktionen verfolgen"
|
||||
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgefuehrte Eingriffe deutlich schneller."
|
||||
title="Auffällige Muster und Admin-Aktionen verfolgen"
|
||||
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgeführte Eingriffe deutlich schneller."
|
||||
:icon="ShieldAlert"
|
||||
/>
|
||||
|
||||
@@ -78,8 +78,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikopruefung</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
|
||||
<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
|
||||
@@ -110,7 +110,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
/>
|
||||
</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 geprueften Fall.
|
||||
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">
|
||||
@@ -170,10 +170,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p>
|
||||
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen für Kategorie-, Kandidaten- und Review-Änderungen.</p>
|
||||
</div>
|
||||
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
|
||||
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Eintraege
|
||||
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Einträge
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -182,7 +182,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
v-model="auditFilter"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border border-violet-200 px-4 py-3"
|
||||
placeholder="Audit-Eintraege nach Aktion, Admin oder Objekt filtern"
|
||||
placeholder="Audit-Einträge nach Aktion, Admin oder Objekt filtern"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -204,10 +204,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
|
||||
</div>
|
||||
|
||||
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine Audit-Eintraege vorhanden.
|
||||
Noch keine Audit-Einträge vorhanden.
|
||||
</p>
|
||||
<p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Audit-Eintraege passen zum aktuellen Filter.
|
||||
Keine Audit-Einträge passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -176,11 +176,11 @@ async function createCategory() {
|
||||
<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">Award-Jahr steuern</h2>
|
||||
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
|
||||
Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr oeffentlich fuer Community, Voting und Archiv sichtbar ist.
|
||||
Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr öffentlich für Community, Voting und Archiv sichtbar ist.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
|
||||
{{ seasonForm.isCurrent ? 'Oeffentlich aktiv' : 'Intern vorbereitet' }}
|
||||
{{ seasonForm.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,16 +195,16 @@ async function createCategory() {
|
||||
placeholder="z.B. Community Voting, Nominierung, Archiviert"
|
||||
/>
|
||||
<span class="block text-xs leading-5 text-slate-500">
|
||||
Diese Phase wird als Orientierung fuer Team und spaeter auch fuer Public-Kommunikation genutzt.
|
||||
Diese Phase wird als Orientierung für Team und später auch für Public-Kommunikation genutzt.
|
||||
</span>
|
||||
</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="seasonForm.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 oeffentlich schalten</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">
|
||||
Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig oeffentlich sein.
|
||||
Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig öffentlich sein.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -226,7 +226,7 @@ async function createCategory() {
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm leading-6 text-slate-500">
|
||||
Speichert Phase und Public-Status fuer <strong class="text-slate-700">{{ seasonDetail.name }}</strong>.
|
||||
Speichert Phase und Public-Status für <strong class="text-slate-700">{{ seasonDetail.name }}</strong>.
|
||||
</p>
|
||||
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
|
||||
{{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }}
|
||||
@@ -248,7 +248,7 @@ async function createCategory() {
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p>
|
||||
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie planen</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen spaeter URL, Anzeige und Reihenfolge im Voting.
|
||||
Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen später URL, Anzeige und Reihenfolge im Voting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +268,7 @@ async function createCategory() {
|
||||
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
|
||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklaeren, wofuer diese Kategorie steht." />
|
||||
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklären, wofür diese Kategorie steht." />
|
||||
</label>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
@@ -288,7 +288,7 @@ async function createCategory() {
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm leading-6 text-slate-500">
|
||||
Neue Kategorien sind sofort Teil des gewaehlten Award-Jahres und koennen danach unten weiter bearbeitet werden.
|
||||
Neue Kategorien sind sofort Teil des gewählten Award-Jahres und können danach unten weiter bearbeitet werden.
|
||||
</p>
|
||||
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
|
||||
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
|
||||
@@ -305,7 +305,7 @@ async function createCategory() {
|
||||
<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">Kategorien dieses Jahres</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Pruefe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten.
|
||||
Prüfe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]">
|
||||
|
||||
@@ -29,7 +29,7 @@ const checks = computed(() => [
|
||||
{
|
||||
label: 'Public-Jahr gesetzt',
|
||||
value: seasonDetail.value.isCurrent,
|
||||
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist oeffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
|
||||
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist öffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
@@ -58,7 +58,7 @@ const featureGates = computed(() => [
|
||||
{
|
||||
label: 'Clip-Moderation',
|
||||
state: false,
|
||||
note: 'Admin-API fuer ClipSubmissions fehlt noch und sollte spaeter ergaenzt werden.',
|
||||
note: 'Admin-API für ClipSubmissions fehlt noch und sollte später ergänzt werden.',
|
||||
},
|
||||
])
|
||||
|
||||
@@ -117,8 +117,8 @@ async function saveSettings() {
|
||||
<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-900">Dieses Award-Jahr oeffentlich markieren</span>
|
||||
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext fuer Community, Voting und spaeter Archiv.</span>
|
||||
<span class="block font-semibold text-slate-900">Dieses Award-Jahr öffentlich markieren</span>
|
||||
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext für Community, Voting und später Archiv.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ const adminCounts = computed(() => {
|
||||
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
|
||||
})
|
||||
const logStats = computed(() => [
|
||||
{ label: 'Audit-Eintraege', value: auditEntries.value.length },
|
||||
{ label: 'Audit-Einträge', value: auditEntries.value.length },
|
||||
{ label: 'Admins aktiv', value: adminCounts.value.length },
|
||||
{ label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
|
||||
])
|
||||
@@ -51,7 +51,7 @@ const logStats = computed(() => [
|
||||
<AdminPageHeader
|
||||
eyebrow="User & Logs"
|
||||
title="User-Spuren und Admin-Aktionen"
|
||||
description="Eine kompakte Kontrollansicht fuer Audit-Eintraege, auffaellige User und Admin-Aktivitaet. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
|
||||
description="Eine kompakte Kontrollansicht für Audit-Einträge, auffällige User und Admin-Aktivität. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
|
||||
:icon="UserCog"
|
||||
/>
|
||||
|
||||
@@ -73,14 +73,14 @@ const logStats = computed(() => [
|
||||
<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">Aktivitaet</h2>
|
||||
<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-Aktivitaet vorhanden.
|
||||
Noch keine Admin-Aktivität vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -100,7 +100,7 @@ const logStats = computed(() => [
|
||||
<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-Eintraege gefunden.</p>
|
||||
<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>
|
||||
@@ -112,7 +112,7 @@ const logStats = computed(() => [
|
||||
<ShieldAlert class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffaellige User</p>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffällige User</p>
|
||||
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@ const logStats = computed(() => [
|
||||
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
|
||||
</div>
|
||||
<p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
|
||||
Keine auffaelligen User fuer den aktuellen Filter.
|
||||
Keine auffälligen User für den aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -57,7 +57,7 @@ const votingChecklist = computed(() => [
|
||||
<AdminPageHeader
|
||||
eyebrow="Voting"
|
||||
title="Voting-Status und Rankings"
|
||||
description="Pruefe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivitaet erzeugen."
|
||||
description="Prüfe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivität erzeugen."
|
||||
:icon="Vote"
|
||||
/>
|
||||
|
||||
@@ -97,7 +97,7 @@ const votingChecklist = computed(() => [
|
||||
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50">
|
||||
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500">
|
||||
@@ -118,7 +118,7 @@ const votingChecklist = computed(() => [
|
||||
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</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' : 'pruefen' }}
|
||||
{{ category.ready ? 'bereit' : 'prüfen' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ const votingChecklist = computed(() => [
|
||||
<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' : 'pruefen' }}
|
||||
{{ item.done ? 'ok' : 'prüfen' }}
|
||||
</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
Reference in New Issue
Block a user