Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
+68 -105
View File
@@ -1,117 +1,51 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ExternalLink, Film, Layers3, PlayCircle, Search, Trash2, TriangleAlert, Users } from '@lucide/vue'
import { CheckCircle2, ExternalLink, Film, Search, Trash2, TriangleAlert, Undo2, Users, XCircle } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminClipManager } from '../../components/admin/useAdminClipManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore()
const query = ref('')
const statusFilter = ref<'all' | 'pending' | 'reviewed'>('all')
const platformFilter = ref<'all' | string>('all')
const categoryFilter = ref('all')
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
const categories = computed(() => seasonDetail.value.categories ?? [])
const categoryName = computed(() =>
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
)
const clips = computed(() => {
const search = query.value.trim().toLowerCase()
return submissions.value.filter((clip) =>
(statusFilter.value === 'all' || clip.status === statusFilter.value || (statusFilter.value === 'reviewed' && clip.status !== 'pending')) &&
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const duplicateUrls = computed(() => {
const counts = new Map<string, number>()
for (const clip of submissions.value) {
const key = clip.clipUrl.trim().toLowerCase()
if (!key) continue
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
})
const stats = computed(() => [
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
])
const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
])
const platformFilters = computed(() => [
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
.sort()
.map((platform) => ({
key: platform,
label: platform,
count: submissions.value.filter((clip) => clip.platform === platform).length,
})),
])
const categoryFilters = computed(() => [
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
...categories.value
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
.map((category) => ({
id: String(category.id),
label: category.name,
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
})),
])
function platformClass(platform: string) {
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
async function confirmDelete() {
if (!clipToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
clipToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
const {
query,
statusFilter,
platformFilter,
categoryFilter,
deleting,
statusSaving,
adminMessage,
adminError,
clipToDelete,
reviewNotes,
submissions,
categoryName,
clips,
stats,
statusFilters,
platformFilters,
categoryFilters,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
confirmDelete,
} = useAdminClipManager()
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
title="Clip-Einreichungen triagieren"
description="Clips sind eine eigene Award-Arbeitsfläche: nach Kategorie, Plattform und Status filtern, Duplikate erkennen, Links prüfen und Spam oder falsche Einreichungen entfernen."
:icon="Film"
/>
<AdminSeasonToolbar />
<section class="grid gap-4 lg:grid-cols-3">
<section class="grid gap-4 lg:grid-cols-4">
<Card v-for="stat in stats" :key="stat.label" class="p-5">
<div class="flex items-start justify-between gap-4">
<div>
@@ -172,27 +106,56 @@ async function confirmDelete() {
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }}
</p>
<p v-if="duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) && duplicateUrls.get(clip.clipUrl.trim().toLowerCase())! > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrls.get(clip.clipUrl.trim().toLowerCase()) }}x eingereicht.
<p v-if="duplicateUrlCount(clip.clipUrl) > 1" class="mt-1 text-xs font-semibold text-amber-700">
Mögliches Duplikat: diese URL wurde {{ duplicateUrlCount(clip.clipUrl) }}x eingereicht.
</p>
<p v-if="creatorClipCount(clip) > 1" class="mt-1 text-xs font-semibold text-violet-700">
Sammelpunkt: {{ creatorClipCount(clip) }} Clips für diese Person oder diesen Kandidaten.
</p>
<p v-if="clip.reviewedAt" class="mt-1 text-xs text-slate-500">
Zuletzt geprüft von {{ clip.reviewedByTwitchId || 'Admin' }} · {{ new Date(clip.reviewedAt).toLocaleString('de-DE') }}
</p>
</div>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<span class="shrink-0 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">{{ clip.status }}</span>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', statusClass(clip.status)]">{{ statusLabel(clip.status) }}</span>
<a
:href="clip.clipUrl"
target="_blank"
rel="noopener"
referrerpolicy="no-referrer"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
>
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
</a>
<button
class="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
<div class="w-full lg:w-[320px]">
<textarea
v-model="reviewNotes[clip.id]"
rows="2"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Moderationsnotiz für Team oder spätere Rückfragen"
/>
<div class="mt-3 flex flex-wrap gap-2">
<Button variant="secondary" class="gap-1.5" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'pending')">
<Undo2 class="h-4 w-4" />
Zurück auf offen
</Button>
<Button class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'approved')">
<CheckCircle2 class="h-4 w-4" />
Freigeben
</Button>
<Button class="gap-1.5 !bg-rose-600 hover:!bg-rose-500" :disabled="statusSaving === clip.id" @click="updateClipStatus(clip, 'rejected')">
<XCircle class="h-4 w-4" />
Ablehnen
</Button>
<button
class="grid h-10 w-10 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div v-if="clips.length === 0" class="px-5 py-12 text-center">