Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -1,168 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
import { ChevronLeft, ChevronRight, Layers3, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue'
|
||||
import { Layers3, UserPlus, Users } from '@lucide/vue'
|
||||
|
||||
import AdminCandidateDeleteModal from '../../components/admin/AdminCandidateDeleteModal.vue'
|
||||
import AdminCandidateEditorModal from '../../components/admin/AdminCandidateEditorModal.vue'
|
||||
import AdminCandidatesFiltersBar from '../../components/admin/AdminCandidatesFiltersBar.vue'
|
||||
import AdminCandidatesTable from '../../components/admin/AdminCandidatesTable.vue'
|
||||
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
|
||||
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
|
||||
import Button from '../../components/ui/Button.vue'
|
||||
import { useAdminCandidateManager } from '../../components/admin/useAdminCandidateManager'
|
||||
import Card from '../../components/ui/Card.vue'
|
||||
import Modal from '../../components/ui/Modal.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
|
||||
/* ---------- Filter + Suche + Pagination ---------- */
|
||||
const search = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
|
||||
)
|
||||
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
|
||||
const categoryLabelMap = computed(() =>
|
||||
Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])),
|
||||
)
|
||||
const duplicateCandidateKeys = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const candidate of seasonDetail.value.candidates) {
|
||||
const categoryKey = `${candidate.categoryId}`
|
||||
const nameKey = `${categoryKey}:name:${candidate.displayName.trim().toLowerCase()}`
|
||||
const slugKey = `${categoryKey}:slug:${candidate.channelSlug.trim().toLowerCase()}`
|
||||
counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1)
|
||||
if (candidate.channelSlug.trim()) counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const duplicateCandidateCount = computed(() =>
|
||||
seasonDetail.value.candidates.filter((candidate) =>
|
||||
(duplicateCandidateKeys.value.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1 ||
|
||||
(duplicateCandidateKeys.value.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1,
|
||||
).length,
|
||||
)
|
||||
|
||||
const filteredCandidates = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
let list = seasonDetail.value.candidates
|
||||
if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
|
||||
if (query) {
|
||||
list = list.filter((c) =>
|
||||
[c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
|
||||
const pagedCandidates = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return filteredCandidates.value.slice(start, start + pageSize)
|
||||
})
|
||||
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
|
||||
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
|
||||
|
||||
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
|
||||
page.value = 1
|
||||
})
|
||||
watch(totalPages, (max) => {
|
||||
if (page.value > max) page.value = max
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
categoryFilter.value = null
|
||||
}
|
||||
|
||||
/* ---------- Modal: anlegen / bearbeiten ---------- */
|
||||
const modalOpen = ref(false)
|
||||
const editingId = ref<number | 'new' | null>(null)
|
||||
const form = reactive({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
|
||||
|
||||
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
|
||||
const canSave = computed(() =>
|
||||
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim()),
|
||||
)
|
||||
|
||||
function openCreate() {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
editingId.value = 'new'
|
||||
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
|
||||
form.displayName = ''
|
||||
form.channelSlug = ''
|
||||
form.platform = 'Twitch'
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(candidate: AdminCandidateItem) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
editingId.value = candidate.id
|
||||
form.categoryId = candidate.categoryId
|
||||
form.displayName = candidate.displayName
|
||||
form.channelSlug = candidate.channelSlug
|
||||
form.platform = candidate.platform
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
async function saveModal() {
|
||||
if (!canSave.value || !selectedSeasonId.value) return
|
||||
saving.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
if (editingId.value === 'new') {
|
||||
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
|
||||
adminMessage.value = `„${form.displayName}" wurde angelegt.`
|
||||
} else if (typeof editingId.value === 'number') {
|
||||
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
|
||||
adminMessage.value = `„${form.displayName}" wurde gespeichert.`
|
||||
}
|
||||
modalOpen.value = false
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Löschen mit Bestätigung ---------- */
|
||||
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!candidateToDelete.value || !selectedSeasonId.value) return
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.`
|
||||
candidateToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
const {
|
||||
seasonDetail,
|
||||
saving,
|
||||
deleting,
|
||||
adminMessage,
|
||||
adminError,
|
||||
search,
|
||||
categoryFilter,
|
||||
page,
|
||||
categoryOptions,
|
||||
categoryFilterOptions,
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
duplicateCandidateCount,
|
||||
filteredCandidates,
|
||||
pagedCandidates,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
modalOpen,
|
||||
form,
|
||||
modalTitle,
|
||||
canSave,
|
||||
candidatePlatformOptions,
|
||||
selectedPlatformValue,
|
||||
candidateToDelete,
|
||||
clearFilters,
|
||||
openCreate,
|
||||
openEdit,
|
||||
handlePlatformSelection,
|
||||
saveModal,
|
||||
confirmDelete,
|
||||
} = useAdminCandidateManager()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<AdminPageHeader
|
||||
eyebrow="Kandidaten"
|
||||
title="Kandidaten verwalten"
|
||||
description="Suchen, filtern, anlegen, bearbeiten und löschen – auch bei vielen Nominierten bleibt die Liste übersichtlich."
|
||||
:icon="Users"
|
||||
/>
|
||||
|
||||
@@ -199,167 +85,59 @@ async function confirmDelete() {
|
||||
</section>
|
||||
|
||||
<Card class="overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
|
||||
<label class="relative block flex-1">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Name, Handle oder Plattform suchen …"
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
v-model="categoryFilter"
|
||||
:options="categoryFilterOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full lg:w-72"
|
||||
/>
|
||||
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
|
||||
<X class="h-4 w-4" /> Filter
|
||||
</Button>
|
||||
<Button class="gap-2" @click="openCreate">
|
||||
<UserPlus class="h-4 w-4" /> Kandidat anlegen
|
||||
</Button>
|
||||
</div>
|
||||
<AdminCandidatesFiltersBar
|
||||
:search="search"
|
||||
:category-filter="categoryFilter"
|
||||
:category-filter-options="categoryFilterOptions"
|
||||
@update:search="search = $event"
|
||||
@update:category-filter="categoryFilter = $event"
|
||||
@clear-filters="clearFilters"
|
||||
@open-create="openCreate"
|
||||
/>
|
||||
|
||||
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||
|
||||
<!-- Tabellenkopf (Desktop) -->
|
||||
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
|
||||
<span>Kandidat</span>
|
||||
<span>Kategorie</span>
|
||||
<span>Plattform</span>
|
||||
<span class="text-right">Aktionen</span>
|
||||
</div>
|
||||
|
||||
<!-- Zeilen -->
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="candidate in pagedCandidates"
|
||||
:key="candidate.id"
|
||||
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
|
||||
{{ candidate.displayName.charAt(0) }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
||||
<p
|
||||
v-if="(duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1 || (duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1"
|
||||
class="mt-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
Mögliches Duplikat in dieser Kategorie
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 lg:justify-end">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
|
||||
title="Bearbeiten"
|
||||
@click="openEdit(candidate)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
|
||||
title="Löschen"
|
||||
@click="candidateToDelete = candidate"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">
|
||||
{{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
|
||||
</p>
|
||||
<Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="filteredCandidates.length > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
|
||||
<span><strong class="text-violet-800">{{ rangeStart }}–{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page <= 1"
|
||||
@click="page--"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page >= totalPages"
|
||||
@click="page++"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminCandidatesTable
|
||||
:paged-candidates="pagedCandidates"
|
||||
:total-count="seasonDetail.candidates.length"
|
||||
:filtered-count="filteredCandidates.length"
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
:range-start="rangeStart"
|
||||
:range-end="rangeEnd"
|
||||
:category-label-map="categoryLabelMap"
|
||||
:duplicate-candidate-keys="duplicateCandidateKeys"
|
||||
@edit="openEdit"
|
||||
@delete="candidateToDelete = $event"
|
||||
@update:page="page = $event"
|
||||
@open-create="openCreate"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- Modal: anlegen / bearbeiten -->
|
||||
<Modal :open="modalOpen" :title="modalTitle" subtitle="Anzeigename und Handle sind Pflicht." @close="modalOpen = false">
|
||||
<div class="space-y-4">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
||||
<Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input v-model="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" />
|
||||
</label>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input v-model="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<input v-model="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube …" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="modalOpen = false">Abbrechen</Button>
|
||||
<Button :disabled="saving || !canSave" @click="saveModal">{{ saving ? 'Speichert …' : 'Speichern' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<AdminCandidateEditorModal
|
||||
:open="modalOpen"
|
||||
:title="modalTitle"
|
||||
:form="form"
|
||||
:category-options="categoryOptions"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
:can-save="canSave"
|
||||
:saving="saving"
|
||||
@close="modalOpen = false"
|
||||
@save="saveModal"
|
||||
@update:category-id="form.categoryId = $event"
|
||||
@update:display-name="form.displayName = $event"
|
||||
@update:channel-slug="form.channelSlug = $event"
|
||||
@update:platform="form.platform = $event"
|
||||
@platform-selection="handlePlatformSelection"
|
||||
/>
|
||||
|
||||
<!-- Modal: löschen bestätigen -->
|
||||
<Modal :open="!!candidateToDelete" title="Kandidat löschen?" @close="candidateToDelete = null">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
„<strong class="text-slate-800">{{ candidateToDelete?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="candidateToDelete = null">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<AdminCandidateDeleteModal
|
||||
:candidate="candidateToDelete"
|
||||
:deleting="deleting"
|
||||
@close="candidateToDelete = null"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user