Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
@@ -0,0 +1,74 @@
<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">Footer Preview</p>
<h3 class="mt-2 text-xl font-bold text-slate-900">{{ title }}</h3>
<p v-if="url" class="mt-2 break-all text-sm text-slate-500">{{ url }}</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="contentHtml"
class="footer-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
v-html="contentHtml"
/>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { X } from '@lucide/vue'
defineProps<{
open: boolean
title: string
url: string
contentHtml: string
}>()
defineEmits<{
close: []
}>()
</script>
<style scoped>
.footer-preview-content :deep(p) {
margin: 0 0 0.85rem;
}
.footer-preview-content :deep(p:last-child),
.footer-preview-content :deep(ul:last-child),
.footer-preview-content :deep(ol:last-child) {
margin-bottom: 0;
}
.footer-preview-content :deep(ul),
.footer-preview-content :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.footer-preview-content :deep(li) {
margin: 0.2rem 0;
}
</style>
@@ -4,13 +4,13 @@
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer & Kontakt</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Rechtliche Links und Kontaktwege</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs und Inhalte werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-3">
<Link2 class="h-6 w-6 text-violet-500" />
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
<Save class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Links speichern' }}
{{ saving ? 'Speichert ...' : 'Footer speichern' }}
</Button>
</div>
</div>
@@ -32,22 +32,73 @@
<input v-model="form.sponsorsUrl" type="url" 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" />
</label>
</div>
<div class="mt-7 space-y-5">
<div class="space-y-3">
<div class="flex 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', 'imprint')">
<Eye class="h-4 w-4" />
Impressum Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.imprintContent"
label="Impressum Inhalt"
placeholder="Impressumstext..."
min-height-class="min-h-[300px]"
/>
</div>
<div class="space-y-3">
<div class="flex 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', 'contact')">
<Eye class="h-4 w-4" />
Kontakt Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.contactContent"
label="Kontakt Inhalt"
placeholder="Kontakttext..."
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex 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', 'sponsors')">
<Eye class="h-4 w-4" />
Sponsoren Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.sponsorsContent"
label="Sponsoren & Partner Inhalt"
placeholder="Sponsor:innen, Partner und Hinweise..."
min-height-class="min-h-[260px]"
/>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Link2, Save } from '@lucide/vue'
import { Eye, Link2, Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': [key: FooterPreviewKey]
}>()
function onSave() {
return props.saveSiteSettings('Footer & Kontakt')
}
@@ -21,15 +21,13 @@
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] space-y-4 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">
<p
v-for="(block, index) in blocks"
:key="`privacy-modal-block-${index}`"
class="whitespace-pre-wrap rounded-[18px] border border-violet-50 bg-white/86 px-4 py-3 shadow-sm"
>
{{ block }}
</p>
<p v-if="!blocks.length" class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
<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="contentHtml"
class="privacy-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
v-html="contentHtml"
/>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Noch kein Datenschutztext eingetragen.
</p>
</div>
@@ -43,7 +41,7 @@ import { X } from '@lucide/vue'
defineProps<{
open: boolean
blocks: string[]
contentHtml: string
updatedLabel: string
}>()
@@ -51,3 +49,25 @@ defineEmits<{
close: []
}>()
</script>
<style scoped>
.privacy-preview-content :deep(p) {
margin: 0 0 0.85rem;
}
.privacy-preview-content :deep(p:last-child),
.privacy-preview-content :deep(ul:last-child),
.privacy-preview-content :deep(ol:last-child) {
margin-bottom: 0;
}
.privacy-preview-content :deep(ul),
.privacy-preview-content :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.privacy-preview-content :deep(li) {
margin: 0.2rem 0;
}
</style>
@@ -31,15 +31,13 @@
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
</div>
</div>
<label class="mt-6 block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz Inhalt</span>
<textarea
v-model="form.privacyPolicyContent"
rows="24"
class="w-full rounded-[28px] border border-violet-200 bg-[#fcfbff] px-5 py-4 text-sm leading-7 text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Datenschutztext..."
/>
</label>
<AdminRichTextEditor
v-model="form.privacyPolicyContent"
class="mt-6"
label="Datenschutz Inhalt"
placeholder="Datenschutztext..."
min-height-class="min-h-[560px]"
/>
</Card>
</section>
</template>
@@ -49,6 +47,7 @@ import { Eye, Save, ShieldCheck } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { KeyRound, Loader2, LockKeyhole, Save, ShieldCheck, Wrench } from '@lucide/vue'
import { ref } from 'vue'
import { KeyRound, Loader2, LockKeyhole, Save, Settings, ShieldCheck, Wrench } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
import AdminSettingsToggle from './AdminSettingsToggle.vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import Modal from '../ui/Modal.vue'
import PasswordField from '../ui/PasswordField.vue'
defineProps<{
form: AdminOperationalSettingsForm
@@ -14,10 +17,16 @@ defineProps<{
error: string
success: string
demoPassword: string
twitchClientSecret: string
demoPasswordHint: string
demoPasswordSet: boolean
demoManagedByDatabase: boolean
demoCredentialsComplete: boolean
twitchSecretHint: string
twitchClientSecretSet: boolean
twitchAuthConfigured: boolean
twitchAuthManagedByDatabase: boolean
twitchAuthComplete: boolean
summary: AdminSettingsStatusSummary[]
dirty: boolean
canManage: boolean
@@ -26,10 +35,17 @@ defineProps<{
const emit = defineEmits<{
save: []
'update:demoPassword': [value: string]
'update:twitchClientSecret': [value: string]
}>()
function readInput(event: Event) {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
const twitchModalOpen = ref(false)
function defaultRedirectUri() {
if (typeof window === 'undefined') {
return 'https://deine-domain.de/api/auth/twitch/callback'
}
return `${window.location.origin.replace(/:\d+$/, ':5084')}/api/auth/twitch/callback`
}
function toneClasses(tone: AdminSettingsTone) {
@@ -89,7 +105,7 @@ function toneClasses(tone: AdminSettingsTone) {
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
</p>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-4">
<div
v-for="item in summary"
:key="item.label"
@@ -102,6 +118,46 @@ function toneClasses(tone: AdminSettingsTone) {
</div>
</div>
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<Settings class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Twitch OAuth</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Offiziellen Twitch Login konfigurieren</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Einmal mit Client-ID, Client Secret und Redirect URI verbinden. Danach verwenden Login und Account-Verknüpfung den offiziellen Twitch-Flow.
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
<span class="rounded-full px-3 py-1" :class="twitchAuthConfigured ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ twitchAuthConfigured ? 'OAuth bereit' : 'OAuth fehlt' }}
</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">
{{ twitchAuthManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}
</span>
<span class="rounded-full px-3 py-1" :class="twitchClientSecretSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ twitchClientSecretSet ? 'Secret vorhanden' : 'Secret fehlt' }}
</span>
</div>
</div>
</div>
<Button
type="button"
variant="secondary"
class="gap-2 rounded-2xl px-5"
:disabled="loading || saving || !canManage"
@click="twitchModalOpen = true"
>
<LockKeyhole v-if="!canManage" class="h-4 w-4" />
<Settings v-else class="h-4 w-4" />
{{ !canManage ? 'Nur Owner' : 'Twitch konfigurieren' }}
</Button>
</div>
</section>
<div class="grid gap-5 xl:grid-cols-2">
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
@@ -140,7 +196,15 @@ function toneClasses(tone: AdminSettingsTone) {
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
<input :value="demoPassword" :disabled="saving || !canManage" type="password" autocomplete="new-password" placeholder="Leer lassen = behalten" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" @input="emit('update:demoPassword', readInput($event))" />
<PasswordField
:model-value="demoPassword"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
@update:model-value="emit('update:demoPassword', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
</label>
<label class="block">
@@ -207,4 +271,90 @@ function toneClasses(tone: AdminSettingsTone) {
</div>
</div>
</Card>
<Modal
:open="twitchModalOpen"
title="Twitch OAuth"
subtitle="Client-Daten aus der Twitch Developer Console. Das Secret wird gespeichert, aber nie wieder angezeigt."
@close="twitchModalOpen = false"
>
<div class="space-y-5">
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
In Twitch muss dieselbe Redirect URI eingetragen sein, die hier gespeichert ist. Ohne eigene Redirect URI nutzt das Backend automatisch den Callback deiner API.
</div>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Client-ID</span>
<input
v-model="form.twitchClientId"
:disabled="saving || !canManage"
type="text"
autocomplete="off"
placeholder="Twitch Client-ID"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Client Secret</span>
<PasswordField
:model-value="twitchClientSecret"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
@update:model-value="emit('update:twitchClientSecret', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ twitchSecretHint }}</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Redirect URI</span>
<input
v-model="form.twitchRedirectUri"
:disabled="saving || !canManage"
type="url"
autocomplete="off"
:placeholder="defaultRedirectUri()"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">Muss exakt in deiner Twitch-App unter OAuth Redirect URLs eingetragen sein.</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Scopes</span>
<input
v-model="form.twitchScope"
:disabled="saving || !canManage"
type="text"
autocomplete="off"
placeholder="Optional, z.B. user:read:email"
class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">Für reines Login/Profil bleibt das Feld normalerweise leer.</span>
</label>
<p
v-if="form.twitchClientId && !twitchAuthComplete"
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
>
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>
<Button type="button" variant="ghost" :disabled="saving" @click="twitchModalOpen = false">Schließen</Button>
<Button type="button" class="gap-2" :disabled="saving || !canManage" @click="emit('save')">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Twitch speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -38,7 +38,7 @@ const emit = defineEmits<{
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText }}</h3>
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3>
<p class="mt-2 text-sm text-slate-500">
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -126,7 +126,7 @@ const emit = defineEmits<{
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
<ul class="mt-2 space-y-1">
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.reviewNote || related.streamUrl || 'ohne Notiz' }}
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
</li>
</ul>
</div>
@@ -22,7 +22,7 @@ defineProps<{
<div v-for="nomination in reviewedNominations" :key="`reviewed-${nomination.id}`" class="rounded-2xl border border-violet-100 bg-white/90 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="font-semibold text-slate-900">{{ nomination.candidateText }}</p>
<p class="font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festgelegt' }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
@@ -32,7 +32,7 @@ const emit = defineEmits<{
ID {{ nomination.id }}
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -0,0 +1,254 @@
<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">
<button type="button" class="rich-editor-tool" title="Fett" aria-label="Fett" @mousedown.prevent @click="runCommand('bold')">
<Bold class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Kursiv" aria-label="Kursiv" @mousedown.prevent @click="runCommand('italic')">
<Italic class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Unterstreichen" aria-label="Unterstreichen" @mousedown.prevent @click="runCommand('underline')">
<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">
<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>
<button type="button" class="rich-editor-tool" title="Zentrieren" aria-label="Zentrieren" @mousedown.prevent @click="runCommand('justifyCenter')">
<AlignCenter class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Rechtsbündig" aria-label="Rechtsbündig" @mousedown.prevent @click="runCommand('justifyRight')">
<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">
<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>
<button type="button" class="rich-editor-tool" title="Nummerierte Liste" aria-label="Nummerierte Liste" @mousedown.prevent @click="runCommand('insertOrderedList')">
<ListOrdered class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Formatierung entfernen" aria-label="Formatierung entfernen" @mousedown.prevent @click="runCommand('removeFormat')">
<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>
<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>
</div>
<div
ref="editorRef"
class="rich-editor w-full px-5 py-4 text-sm leading-7 text-slate-700 outline-none"
:class="[minHeightClass, { 'rich-editor--empty': editorEmpty }]"
contenteditable="true"
role="textbox"
:aria-label="label"
:data-placeholder="placeholder"
@blur="handleEditorBlur"
@focus="isFocused = true"
@input="updateModelFromEditor"
@keyup="saveSelection"
@mouseup="saveSelection"
@paste="handlePaste"
/>
</div>
</label>
</template>
<script setup lang="ts">
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Eraser,
Italic,
List,
ListOrdered,
Type,
Underline,
} from '@lucide/vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { privacyContentToHtml, sanitizePrivacyHtml, stripPrivacyHtml } from '../../lib/privacyContent'
const props = withDefaults(defineProps<{
modelValue: string
label: string
placeholder: string
minHeightClass?: string
}>(), {
minHeightClass: 'min-h-[320px]',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editorRef = ref<HTMLElement | null>(null)
const isFocused = ref(false)
const selectedFont = ref('Outfit')
const selectedSize = ref('3')
let savedSelection: Range | null = null
const fontOptions = ['Outfit', 'Inter', 'Arial', 'Georgia', 'Times New Roman', 'Verdana']
const sizeOptions = [
{ value: '2', label: '13 px' },
{ value: '3', label: '15 px' },
{ value: '4', label: '18 px' },
{ value: '5', label: '22 px' },
]
const editorEmpty = computed(() => !stripPrivacyHtml(props.modelValue))
watch(
() => props.modelValue,
(content) => {
if (!isFocused.value) {
syncEditorContent(content)
}
},
)
onMounted(() => syncEditorContent(props.modelValue))
function syncEditorContent(content: string) {
const editor = editorRef.value
if (!editor) {
return
}
const html = privacyContentToHtml(content)
if (editor.innerHTML !== html) {
editor.innerHTML = html
}
}
function updateModelFromEditor() {
const editor = editorRef.value
if (!editor) {
return
}
saveSelection()
emit('update:modelValue', sanitizePrivacyHtml(editor.innerHTML))
}
function saveSelection() {
const editor = editorRef.value
const selection = window.getSelection()
if (!editor || !selection?.rangeCount) {
return
}
const range = selection.getRangeAt(0)
const selectionInsideEditor = editor.contains(range.commonAncestorContainer)
|| editor === range.commonAncestorContainer
if (selectionInsideEditor) {
savedSelection = range.cloneRange()
}
}
function restoreSelection() {
const editor = editorRef.value
const selection = window.getSelection()
if (!editor || !selection || !savedSelection) {
return
}
const selectionInsideEditor = editor.contains(savedSelection.commonAncestorContainer)
|| editor === savedSelection.commonAncestorContainer
if (!selectionInsideEditor) {
return
}
selection.removeAllRanges()
selection.addRange(savedSelection)
}
function handleEditorBlur() {
saveSelection()
isFocused.value = false
}
async function focusEditor() {
await nextTick()
editorRef.value?.focus()
restoreSelection()
}
async function runCommand(command: string, value?: string) {
await focusEditor()
document.execCommand(command, false, value)
updateModelFromEditor()
}
function applyFont() {
return runCommand('fontName', selectedFont.value)
}
function applySize() {
return runCommand('fontSize', selectedSize.value)
}
function handlePaste(event: ClipboardEvent) {
event.preventDefault()
const html = event.clipboardData?.getData('text/html')
const text = event.clipboardData?.getData('text/plain') ?? ''
const safeHtml = html ? sanitizePrivacyHtml(html) : privacyContentToHtml(text)
document.execCommand('insertHTML', false, safeHtml)
updateModelFromEditor()
}
</script>
<style scoped>
.rich-editor-tool {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border-radius: 999px;
color: #6d5a86;
transition:
background-color 160ms ease,
color 160ms ease;
}
.rich-editor-tool:hover {
background: #f3e8ff;
color: #7c3aed;
}
.rich-editor :deep(p) {
margin: 0 0 0.85rem;
}
.rich-editor :deep(ul),
.rich-editor :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.rich-editor :deep(li) {
margin: 0.2rem 0;
}
.rich-editor--empty::before {
content: attr(data-placeholder);
color: #a9a1b8;
pointer-events: none;
}
</style>
@@ -69,11 +69,11 @@ const props = defineProps<{
<input v-model="props.createForm.votingEndsAt" type="date" 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review startet</span>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aufbereitung startet</span>
<input v-model="props.createForm.reviewStartsAt" type="date" 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" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review endet</span>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aufbereitung endet</span>
<input v-model="props.createForm.reviewEndsAt" type="date" 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" />
</label>
<label class="space-y-2 sm:col-span-2">
@@ -118,7 +118,7 @@ const phaseCards = computed(() =>
...phase,
ordinal: String(index + 1).padStart(2, '0'),
finalLocked,
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Phase aktivieren',
actionTitle: isCompletedPhase
? showHasPassed
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
@@ -46,7 +46,7 @@
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ canActivatePublic || form.isCurrent ? 'Nur ein Jahr sollte öffentlich sichtbar sein.' : 'Erst die Readiness-Blocker unten loesen.' }}
{{ form.isCurrent ? 'Dieses Jahr ist auf der Landingpage sichtbar.' : canActivatePublic ? 'Mit Speichern oder Button als Landingpage-Jahr aktivieren.' : 'Erst die Public-Readiness-Blocker unten loesen.' }}
</span>
</span>
</label>
@@ -55,11 +55,15 @@
<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-3">
<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" />
Löschen
</Button>
<Button variant="ghost" class="w-full gap-2 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="saving || !selectedSeasonId || form.isCurrent || !canActivatePublic" @click="activatePublicSeason">
<Globe2 class="h-4 w-4" />
{{ saving ? 'Aktiviert ...' : 'Public aktivieren' }}
</Button>
<Button variant="ghost" class="w-full gap-2 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="completing || !selectedSeasonId || !canCompleteSelectedSeason" @click="completeSeason">
<CheckCircle2 class="h-4 w-4" />
{{ completing ? 'Schliesst ...' : 'Beenden' }}
@@ -130,7 +134,7 @@
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, History, Trash2 } from '@lucide/vue'
import { AlertTriangle, CheckCircle2, Globe2, History, Trash2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
@@ -155,6 +159,7 @@ defineProps<{
canCompleteSelectedSeason: boolean
selectedSeasonIsCurrent: boolean
openDeleteSeasonModal: () => void
activatePublicSeason: () => Promise<boolean | void> | boolean | void
saveSeason: () => Promise<boolean | void> | boolean | void
completeSeason: () => Promise<void>
}>()
@@ -4,9 +4,9 @@
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen bearbeiten</h2>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen und Pausen bearbeiten</h2>
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Änderungen werden direkt im gewählten Award-Jahr gespeichert.
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Die Aufbereitung ist die planbare Pause zwischen Voting und Show.
</p>
</div>
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
@@ -89,7 +89,7 @@
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
<p class="text-xs leading-5 text-slate-500">
Hinweis: Die Phasen sind als Systemphasen fest verdrahtet, damit Nominierung, Voting, Review und Show sicher mit Public-API und Rate-Limits zusammenspielen.
Hinweis: Nominierung, Voting, Aufbereitung und Show sind als Systemfenster verdrahtet, damit Public-API, Vorschau und Teilnahme-Gates sauber zusammenlaufen.
</p>
</div>
</Card>
@@ -19,8 +19,11 @@ export type AdminContentForm = {
privacyEmail: string
privacyPolicyContent: string
imprintUrl: string
imprintContent: string
contactUrl: string
contactContent: string
sponsorsUrl: string
sponsorsContent: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -1,4 +1,4 @@
export type PhaseKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type PhaseKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export type SeasonDateField =
| 'nominationStartsAt'
@@ -58,9 +58,9 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
editable: true,
},
{
key: 'review',
title: 'Review & Auswertung',
description: 'Team prüft Votes, Clips und Ergebnisse.',
key: 'preparation',
title: 'Aufbereitung',
description: 'Pause vor der Show: Ergebnisse, Clips und Ablauf vorbereiten.',
start: 'reviewStartsAt',
end: 'reviewEndsAt',
editable: true,
@@ -116,7 +116,7 @@ export function normalizePhaseKey(value: string): PhaseKey | string {
const phase = value.trim().toLowerCase()
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
if (phase.includes('show')) return 'show'
if (phase.includes('review') || phase.includes('auswert')) return 'review'
if (phase.includes('aufbereit') || phase.includes('vorbereit') || phase.includes('pause') || phase.includes('review') || phase.includes('auswert')) return 'preparation'
if (phase.includes('vot')) return 'voting'
if (phase.includes('nomin')) return 'nomination'
return phase
@@ -7,6 +7,9 @@ export interface AdminOperationalSettingsForm {
demoLoginIdentifier: string
demoLoginTwitchUserId: string
demoLoginDisplayName: string
twitchClientId: string
twitchRedirectUri: string
twitchScope: string
maintenanceModeEnabled: boolean
maintenanceTitle: string
maintenanceMessage: string
@@ -5,6 +5,7 @@ import {
simpleIconForKey,
socialIconOptionForKey,
} from '../../lib/socialIcons'
import { privacyContentForStorage, privacyContentToHtml } from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
@@ -16,8 +17,11 @@ function createEmptyForm(): AdminContentForm {
privacyEmail: '',
privacyPolicyContent: '',
imprintUrl: '',
imprintContent: '',
contactUrl: '',
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
socialLinks: [],
faq: [],
}
@@ -74,8 +78,11 @@ export function useAdminContentManager() {
form.privacyEmail = settings.privacyEmail
form.privacyPolicyContent = settings.privacyPolicyContent
form.imprintUrl = settings.imprintUrl
form.imprintContent = settings.imprintContent
form.contactUrl = settings.contactUrl
form.contactContent = settings.contactContent
form.sponsorsUrl = settings.sponsorsUrl
form.sponsorsContent = settings.sponsorsContent
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -92,12 +99,7 @@ export function useAdminContentManager() {
{ immediate: true, deep: true },
)
const privacyPreviewBlocks = computed(() =>
form.privacyPolicyContent
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyPreviewHtml = computed(() => privacyContentToHtml(form.privacyPolicyContent))
const privacyUpdatedLabel = computed(() => {
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
@@ -241,10 +243,13 @@ export function useAdminContentManager() {
hostTagline: form.hostTagline,
newsletterUrl: form.newsletterUrl,
privacyEmail: form.privacyEmail,
privacyPolicyContent: form.privacyPolicyContent,
privacyPolicyContent: privacyContentForStorage(form.privacyPolicyContent),
imprintUrl: form.imprintUrl,
imprintContent: privacyContentForStorage(form.imprintContent),
contactUrl: form.contactUrl,
contactContent: privacyContentForStorage(form.contactContent),
sponsorsUrl: form.sponsorsUrl,
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
@@ -277,7 +282,7 @@ export function useAdminContentManager() {
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
@@ -3,6 +3,7 @@ import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/v
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const metricToneMap = {
Nominierungen: {
@@ -29,6 +30,7 @@ const metricToneMap = {
export function useAdminDashboardOverview() {
const store = useAwardsStore()
const authStore = useAuthStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
@@ -108,6 +110,17 @@ export function useAdminDashboardOverview() {
icon: ShieldAlert,
},
])
function canOpenAdminPath(path: string) {
if (path.startsWith('/admin/nominations')) return authStore.hasPermission('nominations')
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
if (path.startsWith('/admin/clips')) return authStore.hasPermission('clips')
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
if (path.startsWith('/admin/analytics')) return authStore.hasPermission('analytics')
return true
}
const priorityActions = computed(() => [
{
label: 'Reviews bearbeiten',
@@ -141,7 +154,7 @@ export function useAdminDashboardOverview() {
icon: Users,
tone: 'emerald',
},
])
].filter((item) => canOpenAdminPath(item.to)))
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
@@ -172,7 +185,7 @@ export function useAdminDashboardOverview() {
state: openRiskCount.value === 0 ? 'ok' : 'danger',
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
},
]
].filter((item) => canOpenAdminPath(item.to))
})
return {
@@ -16,6 +16,10 @@ export function useAdminOperationalSettings() {
const demoPasswordSet = ref(false)
const demoManagedByDatabase = ref(false)
const demoPasswordInput = ref('')
const twitchClientSecretSet = ref(false)
const twitchAuthConfigured = ref(false)
const twitchAuthManagedByDatabase = ref(false)
const twitchClientSecretInput = ref('')
const savedOperationalSnapshot = ref('')
const operationalForm = reactive<AdminOperationalSettingsForm>({
@@ -23,6 +27,9 @@ export function useAdminOperationalSettings() {
demoLoginIdentifier: '',
demoLoginTwitchUserId: '',
demoLoginDisplayName: '',
twitchClientId: '',
twitchRedirectUri: '',
twitchScope: '',
maintenanceModeEnabled: false,
maintenanceTitle: fallbackMaintenanceTitle,
maintenanceMessage: fallbackMaintenanceMessage,
@@ -52,6 +59,23 @@ export function useAdminOperationalSettings() {
),
)
const twitchAuthComplete = computed(() =>
Boolean(operationalForm.twitchClientId.trim())
&& (twitchClientSecretSet.value || Boolean(twitchClientSecretInput.value.trim()))
)
const twitchSecretHint = computed(() => {
if (twitchClientSecretInput.value.trim()) {
return 'Dieses neue Secret wird beim Speichern gesetzt.'
}
if (twitchClientSecretSet.value) {
return 'Client Secret ist gesetzt. Leer lassen, wenn es bleiben soll.'
}
return 'Noch kein Client Secret gesetzt. Beim ersten Speichern erforderlich.'
})
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
{
label: 'Demo Login',
@@ -73,6 +97,14 @@ export function useAdminOperationalSettings() {
: 'Öffentliche Seiten werden normal ausgeliefert.',
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
},
{
label: 'Twitch OAuth',
value: twitchAuthConfigured.value ? 'Konfiguriert' : 'Fehlt',
note: twitchAuthConfigured.value
? twitchAuthManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration'
: 'Offizieller Twitch Login ist noch nicht aktiv.',
tone: twitchAuthConfigured.value ? 'good' : 'warning',
},
{
label: 'Passwort',
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
@@ -91,12 +123,19 @@ export function useAdminOperationalSettings() {
operationalForm.demoLoginIdentifier = response.demoLoginEmail
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
operationalForm.twitchClientId = response.twitchClientId
operationalForm.twitchRedirectUri = response.twitchRedirectUri
operationalForm.twitchScope = response.twitchScope
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
operationalForm.maintenanceTitle = response.maintenanceTitle
operationalForm.maintenanceMessage = response.maintenanceMessage
demoPasswordSet.value = response.demoLoginPasswordSet
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
twitchClientSecretSet.value = response.twitchClientSecretSet
twitchAuthConfigured.value = response.twitchAuthConfigured
twitchAuthManagedByDatabase.value = response.twitchAuthManagedByDatabase
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
rememberSavedOperationalSettings()
}
@@ -106,6 +145,10 @@ export function useAdminOperationalSettings() {
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
twitchClientId: operationalForm.twitchClientId,
twitchRedirectUri: operationalForm.twitchRedirectUri,
twitchScope: operationalForm.twitchScope,
twitchClientSecretInput: twitchClientSecretInput.value,
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
maintenanceTitle: operationalForm.maintenanceTitle,
maintenanceMessage: operationalForm.maintenanceMessage,
@@ -137,8 +180,8 @@ export function useAdminOperationalSettings() {
}
function validateOperationalSettings() {
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 10) {
operationalError.value = 'Das Demo-Passwort muss mindestens 10 Zeichen lang sein.'
return false
}
@@ -147,6 +190,24 @@ export function useAdminOperationalSettings() {
return false
}
const hasAnyTwitchAuthInput = Boolean(
operationalForm.twitchClientId.trim()
|| operationalForm.twitchRedirectUri.trim()
|| operationalForm.twitchScope.trim()
|| twitchClientSecretInput.value.trim()
|| twitchClientSecretSet.value,
)
if (hasAnyTwitchAuthInput && !operationalForm.twitchClientId.trim()) {
operationalError.value = 'Twitch OAuth braucht eine Client-ID.'
return false
}
if (operationalForm.twitchClientId.trim() && !twitchAuthComplete.value) {
operationalError.value = 'Twitch OAuth braucht beim ersten Speichern ein Client Secret.'
return false
}
return true
}
@@ -164,11 +225,14 @@ export function useAdminOperationalSettings() {
...operationalForm,
demoLoginEmail: operationalForm.demoLoginIdentifier,
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
twitchClientSecret: twitchClientSecretInput.value.trim() || undefined,
})
demoPasswordSet.value = result.demoLoginPasswordSet
demoManagedByDatabase.value = true
twitchClientSecretSet.value = result.twitchClientSecretSet
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
await loadOperationalSettings({ silent: true })
clearSiteStatusCache()
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
@@ -187,6 +251,10 @@ export function useAdminOperationalSettings() {
operationalForm.demoLoginIdentifier,
operationalForm.demoLoginTwitchUserId,
operationalForm.demoLoginDisplayName,
operationalForm.twitchClientId,
operationalForm.twitchRedirectUri,
operationalForm.twitchScope,
twitchClientSecretInput.value,
operationalForm.maintenanceModeEnabled,
operationalForm.maintenanceTitle,
operationalForm.maintenanceMessage,
@@ -210,8 +278,14 @@ export function useAdminOperationalSettings() {
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
twitchClientSecretSet,
twitchAuthConfigured,
twitchAuthManagedByDatabase,
twitchClientSecretInput,
demoPasswordHint,
twitchSecretHint,
demoCredentialsComplete,
twitchAuthComplete,
operationalSummary,
hasUnsavedOperationalChanges,
loadOperationalSettings,
@@ -27,7 +27,7 @@ export function useAdminReviewsManager() {
const query = reviewFilter.value.trim().toLowerCase()
return seasonDetail.value.pendingNominations.filter((nomination) =>
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, extractNominationStreamUrl(nomination)]
.join(' ')
.toLowerCase()
.includes(query)),
@@ -74,7 +74,7 @@ export function useAdminReviewsManager() {
return false
}
const sameName = nomination.candidateText.trim().toLowerCase() === selectedName
const sameName = Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
return sameName || sameStreamUrl
})
@@ -84,9 +84,13 @@ export function useAdminReviewsManager() {
if (!selectedNomination.value) return null
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
nomination.categoryId === selectedNomination.value?.categoryId &&
nomination.candidateText.trim().toLowerCase() === selectedName,
(
(Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName) ||
(Boolean(selectedStreamUrl) && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl)
),
)
const submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
const platforms = new Set(
@@ -72,7 +72,7 @@ export function useAdminSeasonManager() {
const selectedSeason = computed(() =>
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
)
const phasePresets = ['Nominierung', 'Community Voting', 'Review & Auswertung', 'Award Show', 'Abgeschlossen']
const phasePresets = ['Nominierung', 'Community Voting', 'Aufbereitung', 'Award Show', 'Abgeschlossen']
const readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
const publicReadinessIssues = computed(() =>
readinessItems.value
@@ -80,6 +80,9 @@ export function useAdminSeasonManager() {
.map((item) => item.note),
)
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
const visibleArchiveReadinessIssues = computed(() =>
normalizePhaseKey(form.currentPhase) === 'completed' ? archiveReadinessIssues.value : [],
)
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
const canCompleteSelectedSeason = computed(() =>
@@ -173,7 +176,7 @@ export function useAdminSeasonManager() {
createForm.votingStartsAt = `${year}-08-25`
createForm.votingEndsAt = `${year}-09-11`
createForm.reviewStartsAt = `${year}-09-12`
createForm.reviewEndsAt = `${year}-09-15`
createForm.reviewEndsAt = `${year}-09-19`
createForm.showDate = `${year}-09-20`
createForm.showStartsAt = '20:00'
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
@@ -229,6 +232,19 @@ export function useAdminSeasonManager() {
return persistSeason('Jahresstatus gespeichert.')
}
async function activatePublicSeason() {
if (!selectedSeasonId.value || saving.value || form.isCurrent) {
return false
}
form.isCurrent = true
const saved = await persistSeason(`Award-Jahr ${form.year} ist jetzt auf der Landingpage aktiv.`)
if (!saved) {
form.isCurrent = false
}
return saved
}
async function activatePhase(phase: string) {
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
return
@@ -392,7 +408,7 @@ export function useAdminSeasonManager() {
selectedSeason,
readinessItems,
publicReadinessIssues,
archiveReadinessIssues,
archiveReadinessIssues: visibleArchiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
@@ -404,6 +420,7 @@ export function useAdminSeasonManager() {
latestSeasonAuditMeta,
canCreate,
activatePhase,
activatePublicSeason,
openCreateModal,
saveSeason,
completeSeason,
@@ -477,7 +494,7 @@ function buildReadinessItems(
label: 'Reviews',
note: detail.pendingNominations.length === 0
? 'Keine offenen Nominierungsreviews.'
: `${detail.pendingNominations.length} Reviews sollten vor Voting-Freeze entschieden werden.`,
: `${detail.pendingNominations.length} Reviews sollten vor dem Voting-Freeze entschieden werden.`,
complete: detail.pendingNominations.length === 0,
blocking: false,
to: '/admin/nominations?review=1',
@@ -31,6 +31,11 @@ export function useAdminSettingsOverview() {
siteSettings.value.sponsorsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const configuredFooterPages = computed(() => [
siteSettings.value.imprintContent,
siteSettings.value.contactContent,
siteSettings.value.sponsorsContent,
].filter((content) => content.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
label: 'Host',
@@ -57,8 +62,8 @@ export function useAdminSettingsOverview() {
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4,
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
icon: Link2,
to: '/admin/content',
},
@@ -0,0 +1,276 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import type { AdminTeamMember, AdminTeamPermission, AdminTeamRole } from '../../types/awards'
export interface TeamMemberForm {
login: string
displayName: string
role: string
isActive: boolean
}
const emptyMemberForm = (): TeamMemberForm => ({
login: '',
displayName: '',
role: 'member',
isActive: true,
})
export function useAdminTeamManager() {
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const generatedPassword = ref('')
const generatedPasswordLogin = ref('')
const confirmDeleteMemberId = ref<number | null>(null)
const members = ref<AdminTeamMember[]>([])
const roles = ref<AdminTeamRole[]>([])
const permissions = ref<AdminTeamPermission[]>([])
const editingMemberId = ref<number | null>(null)
const rolePermissionDrafts = ref<Record<string, string[]>>({})
const savedRoleSnapshot = ref('')
const memberForm = reactive<TeamMemberForm>(emptyMemberForm())
const activeMembers = computed(() => members.value.filter((member) => member.isActive).length)
const pendingPasswordChanges = computed(() => members.value.filter((member) => member.mustChangePassword).length)
const roleOptions = computed(() => roles.value.map((role) => ({ value: role.key, label: role.label })))
const selectedMember = computed(() => members.value.find((member) => member.id === editingMemberId.value) ?? null)
const hasRoleChanges = computed(() =>
Boolean(savedRoleSnapshot.value) && JSON.stringify(rolePermissionDrafts.value) !== savedRoleSnapshot.value,
)
function resetMessages() {
errorMessage.value = ''
successMessage.value = ''
}
function applyTeamResponse(response: { members: AdminTeamMember[]; roles: AdminTeamRole[]; permissions: AdminTeamPermission[] }) {
members.value = response.members
roles.value = response.roles
permissions.value = response.permissions
rolePermissionDrafts.value = Object.fromEntries(
response.roles.map((role) => [role.key, [...role.permissionKeys]]),
)
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
}
async function loadTeam() {
loading.value = true
resetMessages()
try {
applyTeamResponse(await api.getAdminTeam())
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
} finally {
loading.value = false
}
}
function startCreateMember() {
editingMemberId.value = null
Object.assign(memberForm, emptyMemberForm())
generatedPassword.value = ''
generatedPasswordLogin.value = ''
resetMessages()
}
function startEditMember(member: AdminTeamMember) {
editingMemberId.value = member.id
memberForm.login = member.login
memberForm.displayName = member.displayName
memberForm.role = member.role
memberForm.isActive = member.isActive
generatedPassword.value = ''
generatedPasswordLogin.value = ''
resetMessages()
}
function validateMemberForm() {
if (!memberForm.login.trim() && editingMemberId.value === null) {
errorMessage.value = 'Bitte gib einen Login an.'
return false
}
if (!memberForm.displayName.trim()) {
errorMessage.value = 'Bitte gib einen Anzeigenamen an.'
return false
}
return true
}
async function saveMember() {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
if (!validateMemberForm()) return false
saving.value = true
try {
if (editingMemberId.value === null) {
const result = await api.createAdminTeamMember({
login: memberForm.login,
displayName: memberForm.displayName,
role: memberForm.role,
})
await loadTeam()
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = memberForm.login
successMessage.value = 'Team-Login wurde erstellt. Das temporäre Passwort ist nur jetzt sichtbar.'
} else {
await api.updateAdminTeamMember(editingMemberId.value, {
login: memberForm.login,
displayName: memberForm.displayName,
role: memberForm.role,
isActive: memberForm.isActive,
})
await loadTeam()
successMessage.value = 'Team-Mitglied wurde gespeichert.'
}
return true
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Mitglied konnte nicht gespeichert werden.'
return false
} finally {
saving.value = false
}
}
async function resetMemberPassword(member: AdminTeamMember) {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
saving.value = true
try {
const result = await api.resetAdminTeamMemberPassword(member.id)
await loadTeam()
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = member.login
successMessage.value = 'Passwort wurde zurückgesetzt. Das Mitglied muss es beim nächsten Login ändern.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Passwort konnte nicht zurückgesetzt werden.'
} finally {
saving.value = false
}
}
function requestDeleteMember(member: AdminTeamMember) {
confirmDeleteMemberId.value = member.id
resetMessages()
}
function cancelDeleteMember() {
confirmDeleteMemberId.value = null
}
async function deleteMember(member: AdminTeamMember) {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
saving.value = true
try {
await api.deleteAdminTeamMember(member.id)
confirmDeleteMemberId.value = null
if (editingMemberId.value === member.id) {
startCreateMember()
}
await loadTeam()
successMessage.value = 'Team-Mitglied wurde gelöscht.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Mitglied konnte nicht gelöscht werden.'
} finally {
saving.value = false
}
}
function roleHasPermission(roleKey: string, permissionKey: string) {
return rolePermissionDrafts.value[roleKey]?.includes(permissionKey) ?? false
}
function setRolePermission(roleKey: string, permissionKey: string, checked: boolean) {
if (roleKey === 'owner') return
const current = new Set(rolePermissionDrafts.value[roleKey] ?? [])
if (checked) {
current.add(permissionKey)
} else {
current.delete(permissionKey)
}
rolePermissionDrafts.value = {
...rolePermissionDrafts.value,
[roleKey]: [...current].sort(),
}
}
async function saveRolePermissions() {
resetMessages()
saving.value = true
try {
const result = await api.updateAdminTeamRoles({
roles: roles.value.map((role) => ({
key: role.key,
permissionKeys: rolePermissionDrafts.value[role.key] ?? [],
})),
})
roles.value = result.roles
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
successMessage.value = 'Rollen und Berechtigungen wurden gespeichert.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Berechtigungen konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
onMounted(loadTeam)
return {
loading,
saving,
errorMessage,
successMessage,
generatedPassword,
generatedPasswordLogin,
confirmDeleteMemberId,
members,
roles,
permissions,
memberForm,
editingMemberId,
selectedMember,
activeMembers,
pendingPasswordChanges,
roleOptions,
hasRoleChanges,
loadTeam,
startCreateMember,
startEditMember,
saveMember,
resetMemberPassword,
requestDeleteMember,
cancelDeleteMember,
deleteMember,
roleHasPermission,
setRolePermission,
saveRolePermissions,
}
}