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:
AzuTear
2026-06-18 07:53:19 +02:00
parent cca297a4eb
commit 24f9a69022
22 changed files with 504 additions and 145 deletions
@@ -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>
+2
View File
@@ -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`,
+19
View File
@@ -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)
+13
View File
@@ -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>
+113 -73
View File
@@ -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>
+13 -13
View File
@@ -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>
+4 -4
View File
@@ -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>
+10 -10
View File
@@ -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>
+10 -10
View File
@@ -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>
+4 -4
View File
@@ -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>