Refine admin workflows and team access

This commit is contained in:
AzuTear
2026-06-26 18:09:29 +02:00
parent b7804e10ea
commit 8b69dfbafb
71 changed files with 5066 additions and 751 deletions
@@ -16,12 +16,14 @@
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<select :value="selectedPlatformValue" class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" @change="$emit('platform-selection', $event)">
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
<option value="custom">Eigene Plattform</option>
</select>
<NativeSelect
:model-value="selectedPlatformValue"
:options="[
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
{ label: 'Eigene Plattform', value: 'custom' },
]"
@update:model-value="$emit('platform-selection', String($event))"
/>
</label>
</div>
<label v-if="selectedPlatformValue === 'custom'" class="block space-y-2">
@@ -65,6 +67,6 @@ defineEmits<{
'update:displayName': [value: string]
'update:channelSlug': [value: string]
'update:platform': [value: string]
'platform-selection': [event: Event]
'platform-selection': [value: string]
}>()
</script>
@@ -59,34 +59,23 @@
</div>
</div>
<div v-if="filteredCount > 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 {{ filteredCount }}</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="$emit('update:page', page - 1)"
>
<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="$emit('update:page', page + 1)"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="filteredCount"
@update:page="$emit('update:page', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { ChevronLeft, ChevronRight, Pencil, Trash2, UserPlus } from '@lucide/vue'
import { Pencil, Trash2, UserPlus } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
import PaginationFooter from '../ui/PaginationFooter.vue'
const props = defineProps<{
pagedCandidates: AdminCandidateItem[]
@@ -0,0 +1,68 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[120] flex items-center justify-center bg-[#25123f]/70 px-4 py-8 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-[34px] border border-violet-100 bg-white shadow-[0_34px_100px_rgba(38,18,63,0.35)]">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-r from-[#f8f2ff] via-white to-[#fff1f8] px-7 py-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">FAQ Preview</p>
<h3 class="mt-2 text-xl font-bold text-slate-900">Häufige Fragen</h3>
<p class="mt-2 text-sm text-slate-500">{{ visibleFaq.length }} Fragen in der Vorschau</p>
</div>
<button
type="button"
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
aria-label="Preview schließen"
@click="$emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<div v-if="visibleFaq.length" class="grid gap-3">
<article
v-for="(item, index) in visibleFaq"
:key="`${item.question}-${index}`"
class="rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
>
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-500">Frage {{ index + 1 }}</p>
<h4 class="mt-2 text-base font-bold leading-6 text-slate-950">{{ item.question }}</h4>
<p class="mt-3 whitespace-pre-line text-sm font-medium leading-7 text-slate-600">{{ item.answer }}</p>
</article>
</div>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Noch keine vollständigen FAQ-Einträge hinterlegt.
</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { X } from '@lucide/vue'
import { computed } from 'vue'
import type { FaqFormItem } from './adminContentTypes'
const props = defineProps<{
open: boolean
faq: FaqFormItem[]
}>()
defineEmits<{
close: []
}>()
const visibleFaq = computed(() =>
props.faq
.map((item) => ({
question: item.question.trim(),
answer: item.answer.trim(),
}))
.filter((item) => item.question && item.answer),
)
</script>
@@ -7,6 +7,10 @@
<p class="mt-2 text-sm leading-6 text-slate-500">Fragen und Antworten erscheinen im FAQ-Abschnitt der Landingpage.</p>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview')">
<Eye class="h-4 w-4" />
FAQ Preview
</Button>
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addFaqItem">
<Plus class="h-4 w-4" />
FAQ hinzufügen
@@ -33,7 +37,7 @@
</template>
<script setup lang="ts">
import { Plus, Save, Trash2 } from '@lucide/vue'
import { Eye, Plus, Save, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
@@ -47,6 +51,10 @@ const props = defineProps<{
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': []
}>()
function onSave() {
return props.saveSiteSettings('FAQ')
}
@@ -33,26 +33,23 @@
</Button>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)_300px]">
<label class="space-y-2">
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-[minmax(220px,0.85fr)_minmax(260px,1fr)_minmax(260px,0.8fr)]">
<label class="min-w-0 space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="social.label" type="text" class="h-11 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
<input v-model="social.label" type="text" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
</label>
<label class="space-y-2">
<label class="min-w-0 space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform auswählen</span>
<select :value="selectedSocialIconValue(social)" class="h-11 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" @change="handleSocialIconSelection($event, index)">
<optgroup v-for="group in SOCIAL_ICON_OPTION_GROUPS" :key="group.label" :label="group.label">
<option v-for="option in group.options" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</optgroup>
<option value="custom">Andere Plattform / eigenes Icon</option>
</select>
<NativeSelect
:model-value="selectedSocialIconValue(social)"
:options="socialIconSelectOptions"
@update:model-value="handleSocialIconSelection(String($event), index)"
/>
<span class="block text-xs text-slate-400">Bekannte Plattformen nutzen automatisch Bibliotheks-Icons. Neue oder eingestellte Plattformen bleiben über Custom-Key plus Upload möglich.</span>
</label>
<div class="space-y-2">
<div class="min-w-0 space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Custom Icon</span>
<label class="group flex h-11 min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
<label class="group flex h-11 w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
<span class="flex min-w-0 items-center gap-2">
<span class="grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-xl bg-violet-100 text-violet-600">
<Upload class="h-4 w-4" />
@@ -62,12 +59,12 @@
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="sr-only" @change="handleSocialIconUpload($event, index)" />
</label>
</div>
<label v-if="selectedSocialIconValue(social) === 'custom'" class="space-y-2 xl:col-span-3">
<label v-if="selectedSocialIconValue(social) === 'custom'" class="min-w-0 space-y-2 lg:col-span-2 2xl:col-span-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Andere Plattform / eigener Key</span>
<input v-model="social.platform" type="text" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. cake, booth, neue-plattform" />
<span class="block text-xs text-slate-400">Für neue oder nicht mehr gepflegte Plattformen: Key speichern, eigenes Icon hochladen oder Stern-Fallback nutzen.</span>
</label>
<label class="space-y-2 xl:col-span-3">
<label class="min-w-0 space-y-2 lg:col-span-2 2xl:col-span-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">URL</span>
<input v-model="social.url" type="url" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="https://..." />
</label>
@@ -117,6 +114,7 @@ import { Plus, Save, Trash2, Upload } from '@lucide/vue'
import { SOCIAL_ICON_OPTION_GROUPS } from '../../lib/socialIcons'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
const props = defineProps<{
@@ -127,7 +125,7 @@ const props = defineProps<{
removeSocialLink: (index: number) => void
isUploadedIcon: (icon: string) => boolean
selectedSocialIconValue: (social: SocialLinkForm) => string
handleSocialIconSelection: (event: Event, index: number) => void
handleSocialIconSelection: (value: string, index: number) => void
hasSocialIconPreview: (social: SocialLinkForm) => boolean
socialIconModeLabel: (social: SocialLinkForm) => string
socialSimpleIconPath: (social: SocialLinkForm) => string
@@ -137,6 +135,17 @@ const props = defineProps<{
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
const socialIconSelectOptions = [
...SOCIAL_ICON_OPTION_GROUPS.flatMap((group) =>
group.options.map((option) => ({
label: option.label,
value: option.key,
group: group.label,
})),
),
{ label: 'Andere Plattform / eigenes Icon', value: 'custom' },
]
function onSave() {
return props.saveSiteSettings('Social Links')
}
@@ -7,6 +7,7 @@ import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
import { useAdminReviewsManager } from './useAdminReviewsManager'
import { watchAdminToast } from '../../composables/useAdminToast'
const props = defineProps<{
open: boolean
@@ -42,6 +43,8 @@ const {
extractNominationStreamUrl,
} = useAdminReviewsManager()
watchAdminToast(adminMessage, adminError)
function closeModal() {
emit('close')
}
@@ -113,16 +116,40 @@ onBeforeUnmount(() => {
/>
<div class="space-y-4 p-4 sm:p-6">
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
<div class="space-y-2">
<!-- Keyboard shortcut legend fixed above scrollable list -->
<div v-if="filteredNominations.length > 0" class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-2xl border border-violet-100 bg-violet-50/60 px-3 py-2">
<span class="text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">Tastaturkürzel</span>
<span class="flex items-center gap-1.5">
<span class="inline-flex gap-0.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm"></kbd>
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm"></kbd>
</span>
<span class="text-[11px] text-slate-500">/ </span>
<span class="inline-flex gap-0.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">J</kbd>
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">K</kbd>
</span>
<span class="text-[11px] text-slate-500">Navigieren</span>
</span>
<span class="flex items-center gap-1.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-emerald-200 bg-emerald-50 px-1.5 text-[10px] font-bold text-emerald-700 shadow-sm">A</kbd>
<span class="text-[11px] text-slate-500">Annehmen</span>
</span>
<span class="flex items-center gap-1.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-rose-200 bg-rose-50 px-1.5 text-[10px] font-bold text-rose-700 shadow-sm">R</kbd>
<span class="text-[11px] text-slate-500">Ablehnen</span>
</span>
</div>
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
</div>
<AdminReviewDecisionPanel
:nomination="selectedNomination"
@@ -265,10 +265,6 @@ function toneClasses(tone: AdminSettingsTone) {
</section>
</div>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
</Card>
@@ -342,10 +338,6 @@ function toneClasses(tone: AdminSettingsTone) {
Zum Aktivieren fehlt noch ein Client Secret.
</p>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
<template #footer>
@@ -2,6 +2,7 @@
import { CheckCircle2, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
defineProps<{
@@ -27,7 +28,7 @@ defineProps<{
}>()
const emit = defineEmits<{
'platform-change': [event: Event]
'platform-change': [value: string]
approve: [nominationId: number]
reject: [nominationId: number]
}>()
@@ -98,16 +99,14 @@ const emit = defineEmits<{
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<select
:value="selectedPlatformValue(reviewForm.platform)"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@change="emit('platform-change', $event)"
>
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
<option value="custom">Eigene Plattform</option>
</select>
<NativeSelect
:model-value="selectedPlatformValue(reviewForm.platform)"
:options="[
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
{ label: 'Eigene Plattform', value: 'custom' },
]"
@update:model-value="emit('platform-change', String($event))"
/>
</label>
</div>
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
@@ -46,5 +46,6 @@ const emit = defineEmits<{
<p v-else-if="nominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Fälle passen zum aktuellen Filter.
</p>
</div>
</template>
@@ -1,9 +1,9 @@
<template>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ label }}</span>
<div class="overflow-hidden rounded-[28px] border border-violet-200 bg-[#fcfbff] shadow-inner shadow-violet-100/50 transition focus-within:border-violet-400 focus-within:ring-4 focus-within:ring-violet-100">
<div class="flex flex-wrap items-center gap-2 border-b border-violet-100 bg-white/80 px-3 py-3">
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<div class="rich-editor-field">
<span :id="labelId" class="rich-editor-label">{{ label }}</span>
<div class="rich-editor-shell">
<div class="rich-editor-toolbar">
<div class="rich-editor-group" aria-label="Textstil">
<button type="button" class="rich-editor-tool" title="Fett" aria-label="Fett" @mousedown.prevent @click="runCommand('bold')">
<Bold class="h-4 w-4" />
</button>
@@ -14,7 +14,7 @@
<Underline class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<div class="rich-editor-group" aria-label="Ausrichtung">
<button type="button" class="rich-editor-tool" title="Linksbündig" aria-label="Linksbündig" @mousedown.prevent @click="runCommand('justifyLeft')">
<AlignLeft class="h-4 w-4" />
</button>
@@ -25,7 +25,7 @@
<AlignRight class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<div class="rich-editor-group" aria-label="Listen und Formatierung">
<button type="button" class="rich-editor-tool" title="Aufzählung" aria-label="Aufzählung" @mousedown.prevent @click="runCommand('insertUnorderedList')">
<List class="h-4 w-4" />
</button>
@@ -36,26 +36,35 @@
<Eraser class="h-4 w-4" />
</button>
</div>
<label class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
<Type class="h-4 w-4 text-violet-500" />
<select v-model="selectedFont" class="bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applyFont">
<option v-for="font in fontOptions" :key="font" :value="font">{{ font }}</option>
</select>
<label class="rich-editor-select rich-editor-select--font">
<span class="rich-editor-select-label">
<Type class="h-4 w-4" />
Schrift
</span>
<NativeSelect
v-model="selectedFont"
class="rich-editor-native-select"
:options="fontOptions.map((font) => ({ label: font, value: font }))"
@change="applyFont"
/>
</label>
<label class="rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
Größe
<select v-model="selectedSize" class="ml-2 bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applySize">
<option v-for="size in sizeOptions" :key="size.value" :value="size.value">{{ size.label }}</option>
</select>
<label class="rich-editor-select rich-editor-select--size">
<span class="rich-editor-select-label">Größe</span>
<NativeSelect
v-model="selectedSize"
class="rich-editor-native-select"
:options="sizeOptions"
@change="applySize"
/>
</label>
</div>
<div
ref="editorRef"
class="rich-editor w-full px-5 py-4 text-sm leading-7 text-slate-700 outline-none"
class="rich-editor w-full text-sm leading-7 text-slate-700 outline-none"
:class="[minHeightClass, { 'rich-editor--empty': editorEmpty }]"
contenteditable="true"
role="textbox"
:aria-label="label"
:aria-labelledby="labelId"
:data-placeholder="placeholder"
@blur="handleEditorBlur"
@focus="isFocused = true"
@@ -65,7 +74,7 @@
@paste="handlePaste"
/>
</div>
</label>
</div>
</template>
<script setup lang="ts">
@@ -84,6 +93,7 @@ import {
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { privacyContentToHtml, sanitizePrivacyHtml, stripPrivacyHtml } from '../../lib/privacyContent'
import NativeSelect from '../ui/NativeSelect.vue'
const props = withDefaults(defineProps<{
modelValue: string
@@ -102,6 +112,7 @@ const editorRef = ref<HTMLElement | null>(null)
const isFocused = ref(false)
const selectedFont = ref('Outfit')
const selectedSize = ref('3')
const labelId = `rich-editor-label-${Math.random().toString(36).slice(2)}`
let savedSelection: Range | null = null
const fontOptions = ['Outfit', 'Inter', 'Arial', 'Georgia', 'Times New Roman', 'Verdana']
@@ -215,21 +226,146 @@ function handlePaste(event: ClipboardEvent) {
</script>
<style scoped>
.rich-editor-tool {
.rich-editor-field {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border-radius: 999px;
color: #6d5a86;
transition:
background-color 160ms ease,
color 160ms ease;
gap: 14px;
}
.rich-editor-tool:hover {
background: #f3e8ff;
.rich-editor-label {
display: block;
padding-left: 2px;
color: #64748b;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.18em;
line-height: 1.2;
text-transform: uppercase;
}
.rich-editor-shell {
overflow: hidden;
border: 1px solid #ddd6fe;
border-radius: 28px;
background: #fcfbff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 18px 48px rgba(139, 108, 219, 0.08);
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.rich-editor-shell:focus-within {
border-color: #9b7ce4;
box-shadow: 0 0 0 4px rgba(139, 108, 219, 0.12), 0 18px 48px rgba(139, 108, 219, 0.12);
}
.rich-editor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 10px;
padding: 18px;
border-bottom: 1px solid #ede9fe;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 247, 255, 0.88) 100%);
}
.rich-editor-group,
.rich-editor-select {
min-height: 52px;
border: 1px solid #e4def8;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 22px rgba(139, 108, 219, 0.08);
}
.rich-editor-group {
display: flex;
align-items: center;
gap: 4px;
padding: 7px;
}
.rich-editor-tool {
display: grid;
height: 36px;
width: 36px;
place-items: center;
border: 1px solid transparent;
border-radius: 13px;
background: transparent;
color: #6d5a86;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.rich-editor-tool:hover,
.rich-editor-tool:focus-visible {
border-color: #d8c9fb;
background: #f4edff;
color: #7c3aed;
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(139, 108, 219, 0.14);
}
.rich-editor-tool:active {
transform: translateY(0);
box-shadow: none;
}
.rich-editor-tool:focus-visible {
outline: none;
}
.rich-editor-select {
display: grid;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px 10px;
}
.rich-editor-select--font {
max-width: 260px;
}
.rich-editor-select--size {
max-width: 170px;
}
.rich-editor-select-label {
display: flex;
align-items: center;
gap: 7px;
color: #64748b;
font-size: 0.68rem;
font-weight: 850;
letter-spacing: 0.16em;
line-height: 1;
text-transform: uppercase;
}
.rich-editor-select-label svg {
color: #8b5cf6;
}
.rich-editor-native-select :deep(.native-select__trigger) {
min-height: 42px;
padding: 6px 7px 6px 12px;
border-radius: 15px;
font-size: 0.9rem;
}
.rich-editor-native-select :deep(.native-select__chevron) {
width: 30px;
height: 30px;
border-radius: 12px;
}
.rich-editor {
padding: 24px 26px 28px;
background: #fcfbff;
}
.rich-editor :deep(p) {
@@ -251,4 +387,21 @@ function handlePaste(event: ClipboardEvent) {
color: #a9a1b8;
pointer-events: none;
}
@media (min-width: 640px) {
.rich-editor-select {
width: auto;
}
}
@media (max-width: 639px) {
.rich-editor-toolbar {
padding: 14px;
}
.rich-editor-group {
width: 100%;
justify-content: space-between;
}
}
</style>
@@ -10,8 +10,6 @@ defineProps<{
totalOpen: number
loadedLabel: string
loading: boolean
message: string
error: string
stats: Array<{ label: string; value: number; tone: string }>
severityFilters: Array<{ key: 'all' | 'high' | 'medium' | 'low'; label: string; count: number }>
}>()
@@ -96,11 +94,5 @@ function statToneClass(tone: string) {
</div>
</div>
<p v-if="message" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ message }}
</p>
<p v-if="error" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ error }}
</p>
</section>
</template>
@@ -3,6 +3,7 @@ import { Save, SlidersHorizontal } from '@lucide/vue'
import type { AdminRiskRule } from '../../types/awards'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
rules: AdminRiskRule[]
@@ -76,15 +77,15 @@ const emit = defineEmits<{
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Severity</span>
<select
:value="rule.severity"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@change="emit('updateRule', rule.key, { severity: ($event.target as HTMLSelectElement).value })"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<NativeSelect
:model-value="rule.severity"
:options="[
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
]"
@update:model-value="emit('updateRule', rule.key, { severity: String($event) })"
/>
</label>
<label class="flex h-10 items-center gap-2 rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700">
@@ -52,9 +52,6 @@
</label>
</div>
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" role="status">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">{{ adminError }}</p>
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-4">
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
<Trash2 class="h-4 w-4" />
@@ -34,8 +34,6 @@ defineProps<{
</Button>
</div>
<p v-if="healthError" class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ healthError }}</p>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider</p>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { useAdminToast } from '../../composables/useAdminToast'
const { toast, dismissAdminToast } = useAdminToast()
function iconForTone(tone: string) {
if (tone === 'success') return CheckCircle2
if (tone === 'error') return AlertTriangle
return Info
}
</script>
<template>
<Teleport to="body">
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4 sm:top-5">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="-translate-y-8 scale-95 opacity-0"
enter-to-class="translate-y-0 scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 scale-100 opacity-100"
leave-to-class="-translate-y-6 scale-95 opacity-0"
>
<div
v-if="toast"
:key="toast.id"
class="pointer-events-auto flex w-full max-w-[min(560px,calc(100vw-2rem))] items-center gap-3 rounded-full border bg-white/96 px-4 py-3 shadow-[0_24px_70px_rgba(76,40,160,0.22)] backdrop-blur-xl sm:px-5"
:class="toast.tone === 'success'
? 'border-emerald-200 text-emerald-900'
: toast.tone === 'error'
? 'border-rose-200 text-rose-900'
: 'border-violet-200 text-violet-950'"
:role="toast.tone === 'error' ? 'alert' : 'status'"
aria-live="polite"
>
<span
class="grid h-9 w-9 shrink-0 place-items-center rounded-full"
:class="toast.tone === 'success'
? 'bg-emerald-50 text-emerald-600'
: toast.tone === 'error'
? 'bg-rose-50 text-rose-600'
: 'bg-violet-50 text-violet-700'"
>
<component :is="iconForTone(toast.tone)" class="h-4.5 w-4.5" />
</span>
<p class="min-w-0 flex-1 text-sm font-semibold leading-5 text-slate-800">
{{ toast.message }}
</p>
<button
type="button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
aria-label="Toast schließen"
@click="dismissAdminToast(toast.id)"
>
<X class="h-4 w-4" />
</button>
</div>
</Transition>
</div>
</Teleport>
</template>
@@ -32,6 +32,7 @@ export function useAdminAnalyticsManager() {
reviews,
hasWinner,
votes,
votePct: totalVotes.value > 0 ? Math.round((votes / totalVotes.value) * 100) : 0,
status,
statusClass: candidates === 0
? 'border-rose-100 bg-rose-50 text-rose-700'
@@ -40,20 +41,37 @@ export function useAdminAnalyticsManager() {
: hasWinner
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-sky-100 bg-sky-50 text-sky-700',
statusDot: candidates === 0 ? 'bg-rose-400' : reviews > 0 ? 'bg-amber-400' : hasWinner ? 'bg-emerald-400' : 'bg-sky-400',
}
})
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
)
const categoryGroups = computed(() => {
const groups = new Map<string, typeof categoryHealth.value>()
for (const cat of categoryHealth.value) {
const key = cat.groupName || 'Ohne Gruppe'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(cat)
}
return [...groups.entries()].map(([name, cats]) => ({ name, cats }))
})
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
const categoriesReady = computed(() => categoryHealth.value.filter((c) => c.status === 'Bereit' || c.status === 'Gewinner gesetzt'))
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const winnerCoveragePct = computed(() => {
const categoryCount = seasonDetail.value.categories.length
if (categoryCount === 0) return 0
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
})
const readinessPct = computed(() => {
const total = categoryHealth.value.length
if (total === 0) return 0
return Math.round((categoriesReady.value.length / total) * 100)
})
const metricCards = computed(() => [
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
@@ -140,14 +158,37 @@ export function useAdminAnalyticsManager() {
},
])
// Top categories enriched with vote share %
const topCategoriesEnriched = computed(() =>
topCategories.value.map((cat) => ({
...cat,
pct: totalVotes.value > 0 ? Math.round((cat.votes / totalVotes.value) * 100) : 0,
barWidth: totalVotes.value > 0 ? Math.max(2, Math.round((cat.votes / maxVotes.value) * 100)) : 2,
})),
)
// Health counts for status summary
const healthSummary = computed(() => ({
leer: emptyCategories.value.length,
reviewOffen: categoriesWithReviews.value.length,
bereit: categoryHealth.value.filter((c) => c.status === 'Bereit').length,
gewinner: categoryHealth.value.filter((c) => c.status === 'Gewinner gesetzt').length,
total: categoryHealth.value.length,
}))
return {
categoryHealth,
categoryGroups,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
topCategoriesEnriched,
maxVotes,
winnerCoveragePct,
readinessPct,
healthSummary,
totalVotes,
}
}
@@ -121,8 +121,7 @@ export function useAdminCandidateManager() {
modalOpen.value = true
}
function handlePlatformSelection(event: Event) {
const value = (event.target as HTMLSelectElement).value
function handlePlatformSelection(value: string) {
if (value === 'custom') {
if (socialIconOptionForValue(form.platform)) {
form.platform = ''
@@ -17,6 +17,7 @@ export function useAdminClipManager() {
const categoryFilter = ref('all')
const deleting = ref(false)
const statusSaving = ref<number | null>(null)
const bulkSaving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
@@ -38,6 +39,23 @@ export function useAdminClipManager() {
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const PAGE_SIZE = 10
const page = ref(1)
const sortedClips = computed(() =>
[...clips.value].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
)
const totalPages = computed(() => Math.max(1, Math.ceil(sortedClips.value.length / PAGE_SIZE)))
const pagedClips = computed(() => sortedClips.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
const rangeStart = computed(() => sortedClips.value.length === 0 ? 0 : (page.value - 1) * PAGE_SIZE + 1)
const rangeEnd = computed(() => Math.min(page.value * PAGE_SIZE, sortedClips.value.length))
watch([query, statusFilter, platformFilter, categoryFilter, () => submissions.value.length], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
const clipEmbeds = computed<Record<number, ClipEmbed | null>>(() =>
Object.fromEntries(submissions.value.map((clip) => [clip.id, buildClipEmbed(clip.clipUrl)])),
)
@@ -132,6 +150,25 @@ export function useAdminClipManager() {
}
}
async function bulkUpdateStatus(status: 'approved' | 'rejected') {
if (!selectedSeasonId.value || bulkSaving.value) return
const targets = clips.value
if (targets.length === 0) return
bulkSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.bulkUpdateAdminClipStatus(targets.map((c) => c.id), selectedSeasonId.value, status)
const verb = status === 'approved' ? 'freigegeben' : 'abgelehnt'
adminMessage.value = `${targets.length} Clip${targets.length !== 1 ? 's' : ''} wurden ${verb}.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Bulk-Aktion fehlgeschlagen.'
} finally {
bulkSaving.value = false
}
}
function duplicateUrlCount(clipUrl: string) {
return duplicateUrls.value.get(normalizeClipUrl(clipUrl)) ?? 0
}
@@ -154,17 +191,24 @@ export function useAdminClipManager() {
submissions,
categoryName,
clips,
page,
totalPages,
pagedClips,
rangeStart,
rangeEnd,
clipEmbeds,
stats,
statusFilters,
platformFilters,
categoryFilters,
bulkSaving,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
bulkUpdateStatus,
confirmDelete,
}
}
@@ -150,8 +150,7 @@ export function useAdminContentManager() {
: 'custom'
}
function handleSocialIconSelection(event: Event, index: number) {
const value = (event.target as HTMLSelectElement).value
function handleSocialIconSelection(value: string, index: number) {
const social = form.socialLinks[index]
if (!social) {
return
@@ -1,4 +1,4 @@
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
@@ -207,6 +207,54 @@ export function useAdminReviewsManager() {
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as Element
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
(target as HTMLElement).isContentEditable
) return
if (event.ctrlKey || event.altKey || event.metaKey) return
const nominations = filteredNominations.value
const currentIndex = nominations.findIndex((n) => n.id === selectedNomination.value?.id)
switch (event.key) {
case 'ArrowDown':
case 'j': {
event.preventDefault()
const next = nominations[Math.min(currentIndex + 1, nominations.length - 1)]
if (next) selectedNominationId.value = next.id
break
}
case 'ArrowUp':
case 'k': {
event.preventDefault()
const prev = nominations[Math.max(currentIndex - 1, 0)]
if (prev) selectedNominationId.value = prev.id
break
}
case 'a': {
if (selectedNomination.value && canApproveSelected.value && !reviewSaving.value) {
event.preventDefault()
void approveNomination(selectedNomination.value.id)
}
break
}
case 'r': {
if (selectedNomination.value && !reviewSaving.value) {
event.preventDefault()
void rejectNomination(selectedNomination.value.id)
}
break
}
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
function selectedPlatformValue(platform: string) {
return socialIconOptionForValue(platform)?.key ?? 'custom'
}
@@ -226,8 +274,8 @@ export function useAdminReviewsManager() {
form.platform = socialIconOptionForValue(platform)?.label ?? platform
}
function handlePlatformSelection(event: Event) {
setPlatform((event.target as HTMLSelectElement).value)
function handlePlatformSelection(value: string) {
setPlatform(value)
}
return {
@@ -1,4 +1,4 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import type { AdminTeamMember, AdminTeamPermission, AdminTeamRole } from '../../types/awards'
@@ -17,9 +17,13 @@ const emptyMemberForm = (): TeamMemberForm => ({
isActive: true,
})
const TEAM_PRESENCE_REFRESH_MS = 30_000
export function useAdminTeamManager() {
const loading = ref(true)
const saving = ref(false)
const refreshingPresence = ref(false)
const lastPresenceRefreshAt = ref<Date | null>(null)
const errorMessage = ref('')
const successMessage = ref('')
const generatedPassword = ref('')
@@ -60,18 +64,34 @@ export function useAdminTeamManager() {
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
}
async function loadTeam() {
loading.value = true
resetMessages()
async function loadTeam(options: { silent?: boolean } = {}) {
const silent = options.silent === true
const showLoading = !silent || members.value.length === 0
if (showLoading) {
loading.value = true
} else {
refreshingPresence.value = true
}
if (!silent) {
resetMessages()
}
try {
applyTeamResponse(await api.getAdminTeam())
lastPresenceRefreshAt.value = new Date()
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
if (!silent) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
}
} finally {
loading.value = false
if (showLoading) {
loading.value = false
}
refreshingPresence.value = false
}
}
@@ -123,7 +143,7 @@ export function useAdminTeamManager() {
displayName: memberForm.displayName,
role: memberForm.role,
})
await loadTeam()
await loadTeam({ silent: true })
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = memberForm.login
successMessage.value = 'Team-Login wurde erstellt. Das temporäre Passwort ist nur jetzt sichtbar.'
@@ -134,7 +154,7 @@ export function useAdminTeamManager() {
role: memberForm.role,
isActive: memberForm.isActive,
})
await loadTeam()
await loadTeam({ silent: true })
successMessage.value = 'Team-Mitglied wurde gespeichert.'
}
return true
@@ -156,7 +176,7 @@ export function useAdminTeamManager() {
try {
const result = await api.resetAdminTeamMemberPassword(member.id)
await loadTeam()
await loadTeam({ silent: true })
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = member.login
successMessage.value = 'Passwort wurde zurückgesetzt. Das Mitglied muss es beim nächsten Login ändern.'
@@ -190,7 +210,7 @@ export function useAdminTeamManager() {
if (editingMemberId.value === member.id) {
startCreateMember()
}
await loadTeam()
await loadTeam({ silent: true })
successMessage.value = 'Team-Mitglied wurde gelöscht.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
@@ -244,11 +264,29 @@ export function useAdminTeamManager() {
}
}
onMounted(loadTeam)
let presenceRefreshTimer: ReturnType<typeof window.setInterval> | null = null
onMounted(() => {
void loadTeam()
presenceRefreshTimer = window.setInterval(() => {
if (!saving.value) {
void loadTeam({ silent: true })
}
}, TEAM_PRESENCE_REFRESH_MS)
})
onBeforeUnmount(() => {
if (presenceRefreshTimer) {
window.clearInterval(presenceRefreshTimer)
presenceRefreshTimer = null
}
})
return {
loading,
saving,
refreshingPresence,
lastPresenceRefreshAt,
errorMessage,
successMessage,
generatedPassword,
@@ -40,6 +40,9 @@ const props = defineProps<{
const activeFooterLink = ref<FooterLink | null>(null)
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
const safeNewsletterUrl = computed(() =>
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
)
function openFooterLink(event: Event, link: FooterLink) {
if (!link.content.trim()) {
@@ -56,6 +59,17 @@ function openFooterLink(event: Event, link: FooterLink) {
function closeFooterLink() {
activeFooterLink.value = null
}
function isSafePublicUrl(value: string | null | undefined) {
if (!value) return false
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
</script>
<template>
@@ -98,7 +112,7 @@ function closeFooterLink() {
</template>
</a>
</div>
<a :href="props.siteContent.newsletterUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:15px 26px;border-radius:14px;background:#fff;border:1px solid #e3d8f5;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 8px 22px rgba(124,86,196,.1);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>Newsletter abonnieren</a>
<a v-if="safeNewsletterUrl" :href="safeNewsletterUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:15px 26px;border-radius:14px;background:#fff;border:1px solid #e3d8f5;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 8px 22px rgba(124,86,196,.1);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>Newsletter abonnieren</a>
</div>
<img class="home-community-card__image" src="/assets/jayu-hero.png" alt="Jayuhime" style="position:absolute;z-index:1;right:-26px;bottom:0;height:430px;width:auto;pointer-events:none;filter:drop-shadow(0 18px 36px rgba(120,80,180,.22));" />
</div>
@@ -7,7 +7,7 @@ import type { OverviewResponse } from '../../types/awards'
export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewResponse['siteContent']>) {
const siteSocialLinks = computed(() =>
(siteContent.value.socialLinks ?? [])
.filter((social) => social?.url && social.platform)
.filter((social) => isSafePublicUrl(social?.url) && social.platform)
.map((social) => ({
label: social.label || social.platform || 'Social Link',
platform: social.platform || 'link',
@@ -27,7 +27,7 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
)
const footerLinks = computed(() =>
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (link.url || link.content)),
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (isSafePublicUrl(link.url) || link.content)),
)
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
@@ -48,6 +48,17 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
return `#${simpleIconForKey(platform)?.hex ?? '5f44ad'}`
}
function isSafePublicUrl(value: string | null | undefined) {
if (!value) return false
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
return {
hostSocialLinks,
communitySocialLinks,
+4 -4
View File
@@ -5,13 +5,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center rounded-xl border text-sm font-semibold shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50 disabled:shadow-none',
{
variants: {
variant: {
default: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500',
secondary: 'border border-amber-300/60 bg-white text-amber-600 hover:bg-amber-50',
ghost: 'bg-white/70 text-slate-700 hover:bg-white',
default: 'border-violet-600 bg-violet-600 text-white shadow-violet-500/20 hover:border-violet-500 hover:bg-violet-500 hover:shadow-violet-500/30',
secondary: 'border-amber-300/70 bg-amber-50 text-amber-700 shadow-amber-200/30 hover:border-amber-400 hover:bg-amber-100 hover:text-amber-800 hover:shadow-amber-200/60',
ghost: 'border-violet-200 bg-white text-violet-700 shadow-violet-100/50 hover:border-violet-300 hover:bg-violet-50 hover:text-violet-800 hover:shadow-violet-200/70',
},
size: {
default: 'h-11 px-5',
+13 -4
View File
@@ -1,15 +1,24 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { X } from '@lucide/vue'
const props = defineProps<{
const props = withDefaults(defineProps<{
open: boolean
title?: string
subtitle?: string
}>()
size?: 'md' | 'lg' | 'xl'
}>(), {
size: 'md',
})
const emit = defineEmits<{ (e: 'close'): void }>()
const panelSizeClass = computed(() => ({
md: 'max-w-lg',
lg: 'max-w-3xl',
xl: 'max-w-6xl',
}[props.size]))
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
@@ -38,7 +47,7 @@ onBeforeUnmount(() => {
@click.self="emit('close')"
>
<div class="absolute inset-0 bg-violet-950/30 backdrop-blur-sm" @click="emit('close')" />
<div class="modal-panel relative z-10 w-full max-w-lg overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
<div class="modal-panel relative z-10 w-full overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]" :class="panelSizeClass">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f3edff,#fff4e6)] px-6 py-5">
<div>
<h3 v-if="title" class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ title }}</h3>
+367 -20
View File
@@ -1,43 +1,390 @@
<template>
<select
:value="modelValue ?? ''"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@change="onChange"
>
<option
v-for="option in options"
:key="`${option.value}`"
:value="stringifyValue(option.value)"
<div ref="rootEl" class="native-select" :class="{ 'native-select--open': open, 'native-select--disabled': disabled }">
<select
class="native-select__native"
:value="selectedKey"
:disabled="disabled"
aria-hidden="true"
tabindex="-1"
>
{{ option.label }}
</option>
</select>
<option
v-for="option in options"
:key="optionKey(option.value)"
:value="optionKey(option.value)"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
<button
type="button"
class="native-select__trigger"
:disabled="disabled"
:aria-expanded="open"
aria-haspopup="listbox"
:aria-controls="listboxId"
@click="toggleDropdown"
@keydown="onButtonKeydown"
>
<span class="native-select__value">{{ selectedOption?.label ?? placeholder }}</span>
<span class="native-select__chevron" aria-hidden="true">
<ChevronDown :size="20" :stroke-width="2.8" />
</span>
</button>
<div v-if="open" :id="listboxId" class="native-select__menu" role="listbox">
<template v-for="(option, index) in options" :key="optionKey(option.value)">
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
{{ option.group }}
</p>
<button
type="button"
class="native-select__option"
:class="{
'native-select__option--active': index === activeIndex,
'native-select__option--selected': isSelected(option),
}"
:disabled="option.disabled"
role="option"
:aria-selected="isSelected(option)"
@mouseenter="setActiveIndex(index)"
@click="selectOption(option)"
>
<span class="native-select__option-label">{{ option.label }}</span>
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
type SelectOptionValue = string | number | null
type SelectOption = {
label: string
value: SelectOptionValue
disabled?: boolean
group?: string
}
const props = defineProps<{
const props = withDefaults(defineProps<{
modelValue: SelectOptionValue
options: SelectOption[]
}>()
placeholder?: string
disabled?: boolean
}>(), {
placeholder: 'Auswählen',
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: SelectOptionValue]
change: [value: SelectOptionValue, option: SelectOption]
}>()
function stringifyValue(value: SelectOptionValue) {
return value === null ? '' : String(value)
const rootEl = ref<HTMLElement | null>(null)
const open = ref(false)
const activeIndex = ref(0)
const listboxId = `native-select-${Math.random().toString(36).slice(2)}`
const selectedOption = computed(() =>
props.options.find((option) => isSameValue(option.value, props.modelValue)) ?? null,
)
const selectedKey = computed(() => optionKey(selectedOption.value?.value ?? null))
watch(
() => [props.modelValue, props.options] as const,
() => {
const selectedIndex = props.options.findIndex((option) => isSameValue(option.value, props.modelValue))
activeIndex.value = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex()
},
{ immediate: true },
)
function optionKey(value: SelectOptionValue) {
return value === null ? 'null:' : `${typeof value}:${value}`
}
function onChange(event: Event) {
const rawValue = (event.target as HTMLSelectElement).value
const selectedOption = props.options.find((option) => stringifyValue(option.value) === rawValue)
emit('update:modelValue', selectedOption?.value ?? null)
function isSameValue(left: SelectOptionValue, right: SelectOptionValue) {
return optionKey(left) === optionKey(right)
}
function isSelected(option: SelectOption) {
return isSameValue(option.value, props.modelValue)
}
function firstEnabledIndex() {
return Math.max(0, props.options.findIndex((option) => !option.disabled))
}
function setActiveIndex(index: number) {
if (!props.options[index]?.disabled) {
activeIndex.value = index
}
}
function toggleDropdown() {
if (props.disabled || props.options.length === 0) return
open.value = !open.value
if (open.value && props.options[activeIndex.value]?.disabled) {
activeIndex.value = firstEnabledIndex()
}
}
function closeDropdown() {
open.value = false
}
function selectOption(option: SelectOption) {
if (props.disabled || option.disabled) return
emit('update:modelValue', option.value)
emit('change', option.value, option)
closeDropdown()
}
function selectActiveOption() {
const option = props.options[activeIndex.value]
if (option) selectOption(option)
}
function moveActive(delta: number) {
if (!props.options.length || props.disabled) return
if (!open.value) {
open.value = true
}
let nextIndex = activeIndex.value
for (let step = 0; step < props.options.length; step += 1) {
nextIndex = (nextIndex + delta + props.options.length) % props.options.length
if (!props.options[nextIndex]?.disabled) {
activeIndex.value = nextIndex
return
}
}
}
function onButtonKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveActive(1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
open.value ? selectActiveOption() : toggleDropdown()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (!rootEl.value?.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
</script>
<style scoped>
.native-select{
position:relative;
width:100%;
min-width:0;
z-index:1;
}
.native-select--open{
z-index:45;
}
.native-select__native{
position:absolute;
width:1px;
height:1px;
opacity:0;
pointer-events:none;
}
.native-select__trigger{
display:flex;
align-items:center;
justify-content:space-between;
gap:14px;
width:100%;
min-height:48px;
padding:8px 9px 8px 15px;
border:2px solid #e8def8;
border-radius:16px;
background:linear-gradient(180deg,#fff 0%,#fbf8ff 100%);
box-shadow:0 8px 22px rgba(139,108,219,.08), inset 0 1px 0 rgba(255,255,255,.9);
color:#1f2337;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:14px;
font-weight:750;
line-height:1.2;
outline:none;
text-align:left;
transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease;
}
.native-select__trigger:hover,
.native-select__trigger:focus-visible,
.native-select--open .native-select__trigger{
border-color:#9b7ce4;
background:#fff;
transform:translateY(-2px);
box-shadow:0 12px 30px rgba(139,108,219,.16), 0 0 0 4px rgba(139,108,219,.12);
}
.native-select__trigger:active{
transform:translateY(0);
}
.native-select__trigger:disabled{
cursor:not-allowed;
border-color:#e5e7eb;
background:#f8fafc;
color:#94a3b8;
box-shadow:none;
}
.native-select__value{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.native-select__chevron{
display:grid;
place-items:center;
flex:none;
width:32px;
height:32px;
border-radius:12px;
background:#f1eafd;
color:#7c5bd2;
box-shadow:inset 0 0 0 1px rgba(139,108,219,.09);
transition:transform .18s ease, background .18s ease, color .18s ease;
}
.native-select--open .native-select__chevron{
transform:rotate(180deg);
background:#835fd8;
color:#fff;
}
.native-select--disabled .native-select__chevron{
background:#eef2f7;
color:#94a3b8;
}
.native-select__menu{
position:absolute;
top:calc(100% + 8px);
left:0;
right:0;
display:grid;
gap:4px;
max-height:286px;
padding:8px;
overflow:auto;
border:1px solid rgba(139,108,219,.18);
border-radius:18px;
background:rgba(255,255,255,.98);
box-shadow:0 24px 54px rgba(63,53,86,.22);
backdrop-filter:blur(14px);
-webkit-backdrop-filter:blur(14px);
}
.native-select__group{
margin:8px 8px 2px;
color:#7f728f;
font-size:10px;
font-weight:800;
letter-spacing:.16em;
line-height:1.2;
text-transform:uppercase;
}
.native-select__group:first-child{
margin-top:2px;
}
.native-select__option{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
width:100%;
min-height:42px;
padding:9px 12px;
border:0;
border-radius:13px;
background:transparent;
color:#514765;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:14px;
font-weight:650;
line-height:1.2;
text-align:left;
transition:background .16s ease, color .16s ease, transform .16s ease;
}
.native-select__option:hover,
.native-select__option--active{
background:#f6f0ff;
color:#4f3a8a;
transform:translateY(-1px);
}
.native-select__option--selected{
background:linear-gradient(135deg,#efe6ff,#e7dcfb);
color:#3f2f70;
font-weight:800;
}
.native-select__option:disabled{
cursor:not-allowed;
color:#a8a0b5;
background:#f8fafc;
}
.native-select__option-label{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.native-select__check{
flex:none;
color:#7c5bd2;
}
</style>
@@ -0,0 +1,38 @@
<template>
<div v-if="filteredCount > 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 {{ filteredCount }}</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="$emit('update:page', page - 1)"
>
<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="$emit('update:page', page + 1)"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/vue'
defineProps<{
page: number
totalPages: number
rangeStart: number
rangeEnd: number
filteredCount: number
}>()
defineEmits<{
'update:page': [page: number]
}>()
</script>