Add team roles and content management updates
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||
import { privacyContentToHtml } from '../lib/privacyContent'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import type { AuthRole } from '../types/awards'
|
||||
@@ -22,6 +23,7 @@ const accountOpen = ref(false)
|
||||
const deleteConfirm = ref(false)
|
||||
const privacyOpen = ref(false)
|
||||
const accountActionError = ref('')
|
||||
const accountActionSuccess = ref('')
|
||||
|
||||
defineExpose({ privacyOpen })
|
||||
|
||||
@@ -36,12 +38,7 @@ const privacyContent = computed(
|
||||
const privacyEmail = computed(
|
||||
() => awardsStore.overview.siteContent.privacyEmail || awardsStore.adminSiteSettings.privacyEmail,
|
||||
)
|
||||
const privacyContentBlocks = computed(() =>
|
||||
privacyContent.value
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
const privacyContentHtml = computed(() => privacyContentToHtml(privacyContent.value))
|
||||
|
||||
async function doLogin() {
|
||||
try {
|
||||
@@ -56,6 +53,21 @@ async function doLogout() {
|
||||
await router.replace({ name: 'login' })
|
||||
}
|
||||
|
||||
async function bindTeamTwitch() {
|
||||
accountActionError.value = ''
|
||||
accountActionSuccess.value = ''
|
||||
try {
|
||||
await authStore.startTwitchAuthorization({
|
||||
purpose: 'team-binding',
|
||||
returnUrl: route.fullPath,
|
||||
})
|
||||
} catch (error) {
|
||||
accountActionError.value = error instanceof Error
|
||||
? error.message
|
||||
: 'Twitch-Login konnte nicht gestartet werden.'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMyData() {
|
||||
accountActionError.value = ''
|
||||
try {
|
||||
@@ -74,6 +86,8 @@ async function deleteMyData() {
|
||||
function closeAccountModal() {
|
||||
accountOpen.value = false
|
||||
deleteConfirm.value = false
|
||||
accountActionError.value = ''
|
||||
accountActionSuccess.value = ''
|
||||
}
|
||||
|
||||
function openPrivacyModal() {
|
||||
@@ -101,6 +115,7 @@ function isActive(to: string) {
|
||||
const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans-serif;font-size:14px;text-decoration:none;display:inline-block;transition:all .15s;'
|
||||
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
|
||||
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -175,9 +190,10 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
||||
:privacy-open="privacyOpen"
|
||||
:delete-confirm="deleteConfirm"
|
||||
:session="authStore.session"
|
||||
:privacy-content-blocks="privacyContentBlocks"
|
||||
:privacy-content-html="privacyContentHtml"
|
||||
:privacy-email="privacyEmail"
|
||||
:account-action-error="accountActionError"
|
||||
:account-action-success="accountActionSuccess"
|
||||
:auth-loading="authStore.loading"
|
||||
@close-account="closeAccountModal"
|
||||
@close-privacy="closePrivacyModal"
|
||||
@@ -185,6 +201,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
||||
@request-delete="requestAccountDeletion"
|
||||
@cancel-delete="cancelAccountDeletion"
|
||||
@logout="doLogout"
|
||||
@bind-team-twitch="bindTeamTwitch"
|
||||
@confirm-delete="deleteMyData"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,9 +8,10 @@ const props = defineProps<{
|
||||
privacyOpen: boolean
|
||||
deleteConfirm: boolean
|
||||
session: AuthSession | null
|
||||
privacyContentBlocks: string[]
|
||||
privacyContentHtml: string
|
||||
privacyEmail: string
|
||||
accountActionError: string
|
||||
accountActionSuccess: string
|
||||
authLoading: boolean
|
||||
}>()
|
||||
|
||||
@@ -21,11 +22,14 @@ defineEmits<{
|
||||
'request-delete': []
|
||||
'cancel-delete': []
|
||||
logout: []
|
||||
'bind-team-twitch': []
|
||||
'confirm-delete': []
|
||||
}>()
|
||||
|
||||
const twitchUserId = computed(() => props.session?.twitchUserId ?? '')
|
||||
const role = computed(() => props.session?.role ?? 'viewer')
|
||||
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
|
||||
const canBindTwitch = computed(() => isTeamSession.value && !props.session?.mustChangePassword)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -67,6 +71,14 @@ const role = computed(() => props.session?.role ?? 'viewer')
|
||||
<span style="color:#6f6685;">Rolle</span>
|
||||
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ role }}</span>
|
||||
</div>
|
||||
<div v-if="session?.teamLogin" style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Team-Login</span>
|
||||
<span style="font-weight:600;color:#3f3556;">@{{ session.teamLogin }}</span>
|
||||
</div>
|
||||
<div v-if="session?.boundTwitchUserId" style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Gebundenes Twitch</span>
|
||||
<span style="font-weight:600;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Einreichungen</span>
|
||||
<span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span>
|
||||
@@ -77,6 +89,34 @@ const role = computed(() => props.session?.role ?? 'viewer')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
v-if="isTeamSession"
|
||||
style="padding:16px;border-radius:14px;background:#f8f5ff;border:1px solid #ede4fb;display:grid;gap:12px;"
|
||||
@submit.prevent="$emit('bind-team-twitch')"
|
||||
>
|
||||
<div>
|
||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#8b6cdb;margin:0 0 4px;">Twitch verbinden</p>
|
||||
<p style="font-size:12.5px;color:#6f6685;margin:0;line-height:1.45;">Verbinde deinen privaten Twitch-Account über den offiziellen Twitch Login. Danach kannst du dich im Admin-Panel per Twitch anmelden.</p>
|
||||
</div>
|
||||
<div v-if="session?.boundTwitchUserId" style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border-radius:12px;background:white;border:1px solid #ede4fb;font-size:13px;">
|
||||
<span style="color:#6f6685;">Aktuell verbunden</span>
|
||||
<span style="font-weight:800;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
|
||||
</div>
|
||||
<p v-if="session?.mustChangePassword" style="font-size:12.5px;color:#b45309;margin:0;font-weight:700;">Bitte ändere zuerst dein temporäres Passwort.</p>
|
||||
<p v-if="accountActionError" style="font-size:12.5px;color:#be123c;margin:0;font-weight:700;">{{ accountActionError }}</p>
|
||||
<p v-if="accountActionSuccess" style="font-size:12.5px;color:#047857;margin:0;font-weight:700;">{{ accountActionSuccess }}</p>
|
||||
<button
|
||||
:disabled="authLoading || !canBindTwitch"
|
||||
type="submit"
|
||||
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 14px;border-radius:12px;border:none;background:#6f4fd1;color:white;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:800;cursor:pointer;disabled:opacity:.6;"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z" />
|
||||
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z" />
|
||||
</svg>
|
||||
{{ authLoading ? 'Twitch wird geöffnet...' : session?.boundTwitchUserId ? 'Twitch neu verbinden' : 'Mit Twitch verbinden' }}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
@click="$emit('open-privacy')"
|
||||
@@ -142,15 +182,9 @@ const role = computed(() => props.session?.role ?? 'viewer')
|
||||
</div>
|
||||
<button style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" @click="$emit('close-privacy')">✕</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:18px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
|
||||
<template v-if="privacyContentBlocks.length">
|
||||
<p
|
||||
v-for="(block, index) in privacyContentBlocks"
|
||||
:key="`shell-privacy-${index}`"
|
||||
style="margin:0;white-space:pre-wrap;"
|
||||
>
|
||||
{{ block }}
|
||||
</p>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
|
||||
<template v-if="privacyContentHtml">
|
||||
<div class="app-shell-privacy-content" v-html="privacyContentHtml" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="background:#f9f6ff;border:1px solid #ede4fb;border-radius:14px;padding:16px;">
|
||||
@@ -158,9 +192,31 @@ const role = computed(() => props.session?.role ?? 'viewer')
|
||||
<p style="margin:0;">Die Datenschutzerklärung wird aus der Landingpage-Konfiguration geladen.</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:0;">Kontakt: {{ privacyEmail }}</p>
|
||||
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:18px 0 0;">Kontakt: {{ privacyEmail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell-privacy-content :deep(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.app-shell-privacy-content :deep(p:last-child),
|
||||
.app-shell-privacy-content :deep(ul:last-child),
|
||||
.app-shell-privacy-content :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-shell-privacy-content :deep(ul),
|
||||
.app-shell-privacy-content :deep(ol) {
|
||||
margin: 0 0 14px 20px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.app-shell-privacy-content :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
privacyModalOpen: boolean
|
||||
privacyContentBlocks: string[]
|
||||
privacyContentHtml: string
|
||||
onClosePrivacy: () => void
|
||||
privacyModalStop: (event: Event) => void
|
||||
accountModalOpen: boolean
|
||||
@@ -32,14 +32,11 @@ const props = defineProps<{
|
||||
</div>
|
||||
<button @click="props.onClosePrivacy" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;">✕</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:14px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||
<p
|
||||
v-for="(block, index) in props.privacyContentBlocks"
|
||||
:key="`privacy-block-${index}`"
|
||||
style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;white-space:pre-wrap;"
|
||||
>
|
||||
{{ block }}
|
||||
</p>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||
<div v-if="props.privacyContentHtml" class="home-privacy-content" v-html="props.privacyContentHtml" />
|
||||
<div v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;">
|
||||
Datenschutzerklärung wird geladen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,3 +96,25 @@ const props = defineProps<{
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-privacy-content :deep(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.home-privacy-content :deep(p:last-child),
|
||||
.home-privacy-content :deep(ul:last-child),
|
||||
.home-privacy-content :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-privacy-content :deep(ul),
|
||||
.home-privacy-content :deep(ol) {
|
||||
margin: 0 0 14px 20px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.home-privacy-content :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@ const props = defineProps<{
|
||||
:style="props.archiveYearButtonStyle(year.active)"
|
||||
>
|
||||
<span>{{ year.label }}</span>
|
||||
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winners.length }}</span>
|
||||
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winnerCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -46,7 +46,7 @@ const props = defineProps<{
|
||||
|
||||
const nominationCatValue = ref(0)
|
||||
const clipCatValue = ref(0)
|
||||
const clipNomValue = ref(0)
|
||||
const clipNomQuery = ref('')
|
||||
|
||||
watch(
|
||||
() => props.catOptions,
|
||||
@@ -61,21 +61,13 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.clipNomOptions,
|
||||
(options) => {
|
||||
clipNomValue.value = resolveOptionValue(clipNomValue.value, options)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
|
||||
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
|
||||
}
|
||||
|
||||
function handleClipCategoryChange(value: number) {
|
||||
clipCatValue.value = value
|
||||
clipNomValue.value = 0
|
||||
clipNomQuery.value = ''
|
||||
notifyClipCategoryChange(value)
|
||||
}
|
||||
|
||||
@@ -134,7 +126,7 @@ function notifyClipCategoryChange(value: number) {
|
||||
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
|
||||
<div>
|
||||
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
|
||||
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Name und Stream-Link gehen direkt in den Admin-Review.</p>
|
||||
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Der Stream-Link geht direkt in den Admin-Review; den Anzeigenamen vergibt das Team.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
|
||||
@@ -145,14 +137,10 @@ function notifyClipCategoryChange(value: number) {
|
||||
:options="props.catOptions"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Name <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="nominationNameRef" type="text" placeholder="Kanalname oder Anzeigename" maxlength="120" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://twitch.tv/kanal oder https://kick.com/kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Twitch, Kick, YouTube oder ein anderer offizieller Kanal-Link.</p>
|
||||
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://plattform.de/dein-kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Offizieller Kanal- oder Stream-Link der Person.</p>
|
||||
</div>
|
||||
<button @click="props.submitNomination" :disabled="props.submitting" :style="props.submitting ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);'" style-hover="transform:translateY(-2px);">
|
||||
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
|
||||
@@ -166,7 +154,7 @@ function notifyClipCategoryChange(value: number) {
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
</div>
|
||||
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
@@ -180,13 +168,19 @@ function notifyClipCategoryChange(value: number) {
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="clipNomValue"
|
||||
data-ref="clipNomRef"
|
||||
label="VTuber fuer Clip auswaehlen"
|
||||
placeholder="Noch keine Kandidat:innen"
|
||||
:options="props.clipNomOptions"
|
||||
<input
|
||||
v-model="clipNomQuery"
|
||||
data-dc-ref="clipNomSearchRef"
|
||||
type="text"
|
||||
list="clip-nominee-options"
|
||||
placeholder="Name suchen"
|
||||
autocomplete="off"
|
||||
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
|
||||
style-focus="border-color:#8b6cdb;"
|
||||
/>
|
||||
<datalist id="clip-nominee-options">
|
||||
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -235,7 +229,7 @@ function notifyClipCategoryChange(value: number) {
|
||||
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
</div>
|
||||
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
@@ -249,13 +243,19 @@ function notifyClipCategoryChange(value: number) {
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="clipNomValue"
|
||||
data-ref="clipNomRef"
|
||||
label="VTuber fuer Clip auswaehlen"
|
||||
placeholder="Noch keine Kandidat:innen"
|
||||
:options="props.clipNomOptions"
|
||||
<input
|
||||
v-model="clipNomQuery"
|
||||
data-dc-ref="clipNomSearchRef"
|
||||
type="text"
|
||||
list="clip-nominee-options"
|
||||
placeholder="Name suchen"
|
||||
autocomplete="off"
|
||||
style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;"
|
||||
style-focus="border-color:#8b6cdb;"
|
||||
/>
|
||||
<datalist id="clip-nominee-options">
|
||||
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -26,12 +26,12 @@ const {
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
faqItems,
|
||||
privacyContentBlocks,
|
||||
privacyContentHtml,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
preparationPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
@@ -145,7 +145,7 @@ const {
|
||||
const previewPhaseButtons = [
|
||||
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
|
||||
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
|
||||
{ key: 'review', label: 'Review', hint: 'Auswertung' },
|
||||
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
|
||||
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
|
||||
] as const
|
||||
|
||||
@@ -162,7 +162,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
||||
archiveYear,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
preparationPhase,
|
||||
completedPhase,
|
||||
initializeHomeInteractions,
|
||||
submitNomination,
|
||||
@@ -250,7 +250,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
||||
<HomeTimelineSection
|
||||
:nomination-phase="nominationPhase"
|
||||
:voting-phase="votingPhase"
|
||||
:review-phase="reviewPhase"
|
||||
:preparation-phase="preparationPhase"
|
||||
:show-phase="showPhase"
|
||||
:completed-phase="completedPhase"
|
||||
:timeline-line-style="timelineLineStyle"
|
||||
@@ -278,7 +278,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
||||
:section-action-disabled="sectionActionDisabled"
|
||||
:section-action-style="sectionActionStyle"
|
||||
:nomination-phase="nominationPhase"
|
||||
:review-phase="reviewPhase"
|
||||
:preparation-phase="preparationPhase"
|
||||
:on-section-action="onSectionAction"
|
||||
/>
|
||||
|
||||
@@ -342,7 +342,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
|
||||
:winner-platform-key="winnerPlatformKey"
|
||||
:winner-platform-label="winnerPlatformLabel"
|
||||
:privacy-modal-open="privacyModalOpen"
|
||||
:privacy-content-blocks="privacyContentBlocks"
|
||||
:privacy-content-html="privacyContentHtml"
|
||||
:on-close-privacy="onClosePrivacy"
|
||||
:privacy-modal-stop="privacyModalStop"
|
||||
:account-modal-open="accountModalOpen"
|
||||
|
||||
@@ -58,7 +58,7 @@ defineProps<{
|
||||
winnerPlatformKey: (url: string) => string
|
||||
winnerPlatformLabel: (url: string) => string
|
||||
privacyModalOpen: boolean
|
||||
privacyContentBlocks: string[]
|
||||
privacyContentHtml: string
|
||||
onClosePrivacy: () => void
|
||||
privacyModalStop: (event: Event) => void
|
||||
accountModalOpen: boolean
|
||||
@@ -132,7 +132,7 @@ defineProps<{
|
||||
|
||||
<HomeAccountAndPrivacyModals
|
||||
:privacy-modal-open="privacyModalOpen"
|
||||
:privacy-content-blocks="privacyContentBlocks"
|
||||
:privacy-content-html="privacyContentHtml"
|
||||
:on-close-privacy="onClosePrivacy"
|
||||
:privacy-modal-stop="privacyModalStop"
|
||||
:account-modal-open="accountModalOpen"
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps<{
|
||||
sectionActionDisabled: boolean
|
||||
sectionActionStyle: string
|
||||
nominationPhase: boolean
|
||||
reviewPhase: boolean
|
||||
preparationPhase: boolean
|
||||
onSectionAction: (event?: Event) => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -72,7 +72,7 @@ const props = defineProps<{
|
||||
:style="props.sectionActionStyle"
|
||||
style-hover="transform:translateY(-2px);"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.reviewPhase ? '#B7791F' : '#9146FF'"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>{{ props.sectionActionLabel }}
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.preparationPhase ? '#B7791F' : '#9146FF'"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>{{ props.sectionActionLabel }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
|
||||
interface HomeSocialLink {
|
||||
label: string
|
||||
platform: string
|
||||
@@ -7,8 +11,10 @@ interface HomeSocialLink {
|
||||
}
|
||||
|
||||
interface FooterLink {
|
||||
key: string
|
||||
label: string
|
||||
url: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface SiteContent {
|
||||
@@ -31,6 +37,25 @@ const props = defineProps<{
|
||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||
platformKey: (platform: string | null | undefined) => string
|
||||
}>()
|
||||
|
||||
const activeFooterLink = ref<FooterLink | null>(null)
|
||||
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
|
||||
|
||||
function openFooterLink(event: Event, link: FooterLink) {
|
||||
if (!link.content.trim()) {
|
||||
if (!link.url.trim()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
activeFooterLink.value = link
|
||||
}
|
||||
|
||||
function closeFooterLink() {
|
||||
activeFooterLink.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -111,10 +136,83 @@ const props = defineProps<{
|
||||
VTuber Star Award 2026
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px;font-size:14px;font-weight:500;">
|
||||
<a v-for="link in props.footerLinks" :key="link.label" :href="link.url" target="_blank" rel="noopener" style="color:var(--muted,#8a8398);text-decoration:none;" style-hover="color:var(--accent,#8b6cdb);">{{ link.label }}</a>
|
||||
<template v-for="link in props.footerLinks" :key="link.key || link.label">
|
||||
<button
|
||||
v-if="link.content"
|
||||
type="button"
|
||||
style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:inherit;"
|
||||
style-hover="color:var(--accent,#8b6cdb);"
|
||||
@click="openFooterLink($event, link)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
:href="link.url || '#'"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color:var(--muted,#8a8398);text-decoration:none;"
|
||||
style-hover="color:var(--accent,#8b6cdb);"
|
||||
@click="openFooterLink($event, link)"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</template>
|
||||
<button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;" style-hover="color:var(--accent,#8b6cdb);">Datenschutz</button>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--muted,#c9b8da);">© 2026 · Made with ♡ & Chaos</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<template v-if="activeFooterLink">
|
||||
<div class="home-modal-overlay" @click="closeFooterLink" style="position:fixed;inset:0;z-index:420;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
||||
<div class="home-modal" @click.stop style="position:relative;width:100%;max-width:720px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:18px;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Footer Seite</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
|
||||
</div>
|
||||
<button @click="closeFooterLink" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;">✕</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||
<div v-if="activeFooterHtml" class="home-footer-page-content" v-html="activeFooterHtml" />
|
||||
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
|
||||
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
|
||||
</p>
|
||||
<a
|
||||
v-if="activeFooterLink.url"
|
||||
:href="activeFooterLink.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="display:inline-flex;align-items:center;gap:9px;margin-top:18px;padding:12px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #e6dcf6;color:#6a4fb8;text-decoration:none;font-weight:700;"
|
||||
style-hover="background:#f1ecfb;"
|
||||
>
|
||||
Externe Seite öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-footer-page-content :deep(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.home-footer-page-content :deep(p:last-child),
|
||||
.home-footer-page-content :deep(ul:last-child),
|
||||
.home-footer-page-content :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-footer-page-content :deep(ul),
|
||||
.home-footer-page-content :deep(ol) {
|
||||
margin: 0 0 14px 20px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.home-footer-page-content :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
|
||||
<div style="text-align:center;margin-bottom:56px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;">✦ Der Ablauf</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Phasen auf die Bühne</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über Review & Auswertung bis ganz zum Schluss zur grossen Show.</p>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Schritten auf die Bühne</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über die Aufbereitung bis ganz zum Schluss zur grossen Show.</p>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
|
||||
@@ -58,29 +58,29 @@
|
||||
</div>
|
||||
|
||||
<div style="position:relative;z-index:1;text-align:center;">
|
||||
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : reviewPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
|
||||
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : preparationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
|
||||
<template v-if="nominationPhase || votingPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</template>
|
||||
<template v-else-if="reviewPhase">
|
||||
<template v-else-if="preparationPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div :style="reviewPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
|
||||
<div :style="reviewPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ reviewPhase ? 'IN PRÜFUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Review & Auswertung</h3>
|
||||
<div :style="reviewPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('review') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team prüft Fairness, Stimmen und Clips, wertet die Ergebnisse aus und bereitet die Show final vor.</p>
|
||||
<div :style="preparationPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
|
||||
<div :style="preparationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ preparationPhase ? 'AUFBEREITUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Aufbereitung</h3>
|
||||
<div :style="preparationPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('preparation') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
:style="reviewButtonStyle(reviewPhase, showPhase, completedPhase)"
|
||||
:style="preparationButtonStyle(preparationPhase, showPhase, completedPhase)"
|
||||
>
|
||||
✦ {{ reviewPhase ? 'Auswertung läuft' : showPhase || completedPhase ? 'Auswertung abgeschlossen' : 'Review folgt' }}
|
||||
✦ {{ preparationPhase ? 'Aufbereitung läuft' : showPhase || completedPhase ? 'Aufbereitung abgeschlossen' : 'Aufbereitung folgt' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,12 +131,12 @@
|
||||
defineProps<{
|
||||
nominationPhase: boolean
|
||||
votingPhase: boolean
|
||||
reviewPhase: boolean
|
||||
preparationPhase: boolean
|
||||
showPhase: boolean
|
||||
completedPhase: boolean
|
||||
timelineLineStyle: string
|
||||
publicStreamUrl: string
|
||||
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
|
||||
formatTimelineRange: (key: 'nomination' | 'voting' | 'preparation' | 'show') => string
|
||||
openNominate: (event?: Event) => void
|
||||
openVote: (event?: Event) => void
|
||||
onTimelineFinalAction: (event?: Event) => void
|
||||
@@ -158,8 +158,8 @@ function voteButtonStyle(votingPhase: boolean) {
|
||||
: "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:none;"
|
||||
}
|
||||
|
||||
function reviewButtonStyle(reviewPhase: boolean, showPhase: boolean, completedPhase: boolean) {
|
||||
if (reviewPhase) {
|
||||
function preparationButtonStyle(preparationPhase: boolean, showPhase: boolean, completedPhase: boolean) {
|
||||
if (preparationPhase) {
|
||||
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#fff1d6;color:#8a5a00;border:1px solid #f3ddae;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,18 @@ import type { CandidateSummary } from '../../types/awards'
|
||||
|
||||
export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip'
|
||||
|
||||
export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
||||
export type HomePreviewPhase = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
|
||||
|
||||
export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
|
||||
|
||||
export interface HomeClipSubmitContext {
|
||||
clipUrl: string
|
||||
selectedNomineeIndex: number
|
||||
selectedNomineeQuery: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface HomeNominationSubmitContext {
|
||||
categoryIndex: number
|
||||
name: string
|
||||
streamUrl: string
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface HomeSelectionOption {
|
||||
export interface HomeArchiveYearItem {
|
||||
year: number
|
||||
label: string
|
||||
winnerCount: number
|
||||
winners: Array<unknown>
|
||||
active: boolean
|
||||
}
|
||||
|
||||
@@ -6,16 +6,30 @@ type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
|
||||
export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<number>) {
|
||||
const archiveYears = computed(() => {
|
||||
const knownYears = new Set<number>(store.overview.winnersPreview.map((entry) => entry.year))
|
||||
if (store.archive.items.length > 0) {
|
||||
knownYears.add(store.archive.year)
|
||||
const knownYears = new Map<number, number>()
|
||||
for (const entry of store.overview.archiveYears ?? []) {
|
||||
knownYears.set(entry.year, entry.winnerCount)
|
||||
}
|
||||
|
||||
return [...knownYears]
|
||||
.sort((left, right) => right - left)
|
||||
.map((year) => ({
|
||||
for (const entry of store.overview.winnersPreview) {
|
||||
if (!knownYears.has(entry.year)) {
|
||||
knownYears.set(
|
||||
entry.year,
|
||||
store.overview.winnersPreview.filter((winner) => winner.year === entry.year).length,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (store.archive.items.length > 0) {
|
||||
knownYears.set(store.archive.year, store.archive.items.length)
|
||||
}
|
||||
|
||||
return [...knownYears.entries()]
|
||||
.sort(([left], [right]) => right - left)
|
||||
.map(([year, winnerCount]) => ({
|
||||
year,
|
||||
label: String(year),
|
||||
winnerCount: year === store.archive.year ? store.archive.items.length : winnerCount,
|
||||
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
|
||||
active: archiveYear.value === year,
|
||||
}))
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandin
|
||||
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
|
||||
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show'
|
||||
|
||||
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
|
||||
|
||||
@@ -40,8 +40,10 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
|
||||
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
|
||||
)
|
||||
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
|
||||
store.overview.winnersPreview.length > 0
|
||||
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
|
||||
(store.overview.archiveYears ?? []).length > 0
|
||||
? (store.overview.archiveYears ?? []).map((entry) => ({ year: entry.year }))
|
||||
: store.overview.winnersPreview.length > 0
|
||||
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
|
||||
: [{ year: store.overview.year - 1 }],
|
||||
)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export function useHomeLandingState() {
|
||||
hostSocialLinks,
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
privacyContentBlocks,
|
||||
privacyContentHtml,
|
||||
platformKey,
|
||||
isUploadedSocialIcon,
|
||||
socialSimpleIconPath,
|
||||
@@ -101,7 +101,7 @@ export function useHomeLandingState() {
|
||||
const {
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
preparationPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
@@ -257,13 +257,13 @@ export function useHomeLandingState() {
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
faqItems,
|
||||
privacyContentBlocks,
|
||||
privacyContentHtml,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
candidateCount,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
preparationPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { HomeNominationSubmitContext } from './homeLandingTypes'
|
||||
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
||||
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
|
||||
|
||||
interface PhaseCountdownTarget {
|
||||
label: string
|
||||
@@ -35,11 +35,11 @@ interface UseHomeLandingViewEffectsParams {
|
||||
archiveYear: Readonly<Ref<number>>
|
||||
nominationPhase: Readonly<Ref<boolean>>
|
||||
votingPhase: Readonly<Ref<boolean>>
|
||||
reviewPhase: Readonly<Ref<boolean>>
|
||||
preparationPhase: Readonly<Ref<boolean>>
|
||||
completedPhase: Readonly<Ref<boolean>>
|
||||
initializeHomeInteractions: () => Promise<void>
|
||||
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
|
||||
submitClip: (clipContext: { clipUrl: string; selectedNomineeIndex: number; description: string }) => Promise<void>
|
||||
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
|
||||
@@ -56,7 +56,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
||||
archiveYear,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
preparationPhase,
|
||||
completedPhase,
|
||||
initializeHomeInteractions,
|
||||
submitNomination,
|
||||
@@ -66,10 +66,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const landingLoaderVisible = ref(true)
|
||||
const nominationCatEl = ref<HTMLSelectElement | null>(null)
|
||||
const nominationNameEl = ref<HTMLInputElement | null>(null)
|
||||
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
|
||||
const clipUrlEl = ref<HTMLInputElement | null>(null)
|
||||
const clipNomEl = ref<HTMLSelectElement | null>(null)
|
||||
const clipNomSearchEl = ref<HTMLInputElement | null>(null)
|
||||
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const countdownRefs = {
|
||||
labelEl: ref<HTMLElement | null>(null),
|
||||
@@ -93,7 +92,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
||||
function handleClipSubmit() {
|
||||
return submitClip({
|
||||
clipUrl: clipUrlEl.value?.value.trim() ?? '',
|
||||
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
|
||||
selectedNomineeQuery: clipNomSearchEl.value?.value.trim() ?? '',
|
||||
description: clipDescEl.value?.value.trim() ?? '',
|
||||
})
|
||||
}
|
||||
@@ -101,7 +100,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
||||
function handleNominationSubmit() {
|
||||
return submitNomination({
|
||||
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
|
||||
name: nominationNameEl.value?.value.trim() ?? '',
|
||||
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
|
||||
})
|
||||
}
|
||||
@@ -120,10 +118,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
||||
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
|
||||
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
|
||||
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
|
||||
nominationNameEl.value = root.querySelector('[data-dc-ref="nominationNameRef"]')
|
||||
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
|
||||
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
|
||||
clipNomEl.value = root.querySelector('[data-dc-ref="clipNomRef"]')
|
||||
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
|
||||
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
|
||||
}
|
||||
|
||||
@@ -178,7 +175,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
|
||||
function resolveSelectedPhaseKey(): HomeTimelineKey {
|
||||
if (nominationPhase.value) return 'nomination'
|
||||
if (votingPhase.value) return 'voting'
|
||||
if (reviewPhase.value) return 'review'
|
||||
if (preparationPhase.value) return 'preparation'
|
||||
if (completedPhase.value) return 'completed'
|
||||
return 'show'
|
||||
}
|
||||
@@ -297,7 +294,7 @@ function resolvePhaseCountdownTarget(
|
||||
}
|
||||
|
||||
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
|
||||
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'review', 'show']
|
||||
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'preparation', 'show']
|
||||
|
||||
return phaseOrder
|
||||
.map((key): TimelineScheduleItem | null => {
|
||||
@@ -358,8 +355,8 @@ function phaseTitle(key: HomeTimelineKey) {
|
||||
? 'Nominierung'
|
||||
: key === 'voting'
|
||||
? 'Voting'
|
||||
: key === 'review'
|
||||
? 'Review & Auswertung'
|
||||
: key === 'preparation'
|
||||
? 'Aufbereitung'
|
||||
: 'Award Show'
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export function useHomeParticipationActions(params: {
|
||||
openModal('vote')
|
||||
return
|
||||
}
|
||||
if (previewPhase.value === 'review') {
|
||||
if (previewPhase.value === 'preparation') {
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -180,7 +180,7 @@ export function useHomeParticipationActions(params: {
|
||||
}
|
||||
|
||||
function isHomePreviewPhaseKey(value: string | undefined): value is HomePreviewPhase {
|
||||
return value === 'nomination' || value === 'voting' || value === 'review' || value === 'show'
|
||||
return value === 'nomination' || value === 'voting' || value === 'preparation' || value === 'show'
|
||||
}
|
||||
|
||||
function isCompletedPhase(value: string) {
|
||||
|
||||
@@ -35,9 +35,9 @@ export function useHomeParticipationPresentation(params: {
|
||||
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
|
||||
const activeCatName = computed(() => activeCategory.value?.name ?? '')
|
||||
const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt'))
|
||||
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche Name und Stream-Link ein. Optional kannst du direkt einen Clip mitschicken.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
|
||||
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
|
||||
const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩')
|
||||
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Name und Stream-Link wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
|
||||
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Der Stream-Link wurde gespeichert und landet im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
|
||||
const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` })))
|
||||
const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` })))
|
||||
const canSubmitClip = computed(() => previewPhase.value === 'nomination' && clipDsgvo.value)
|
||||
|
||||
@@ -113,13 +113,7 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
return
|
||||
}
|
||||
|
||||
const name = nominationContext.name.trim()
|
||||
const streamUrl = nominationContext.streamUrl.trim()
|
||||
if (!name) {
|
||||
formError.value = 'Bitte gib den Namen des VTubers oder Streamers ein.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!streamUrl) {
|
||||
formError.value = 'Bitte füge einen Stream- oder Kanal-Link hinzu.'
|
||||
return
|
||||
@@ -143,7 +137,7 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
year: store.overview.year,
|
||||
categoryId: Number(category.id),
|
||||
twitchUserId: session.twitchUserId,
|
||||
nominations: [{ name, streamUrl }],
|
||||
nominations: [{ streamUrl }],
|
||||
})
|
||||
await loadMyParticipation()
|
||||
submitted.value = true
|
||||
@@ -165,7 +159,12 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
|
||||
const url = clipContext.clipUrl.trim()
|
||||
if (!url) {
|
||||
formError.value = 'Bitte gib einen Twitch- oder YouTube-Clip-Link ein.'
|
||||
formError.value = 'Bitte gib einen Clip-Link ein.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHttpUrl(url)) {
|
||||
formError.value = 'Bitte gib einen gültigen http(s)-Link ein.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,7 +172,12 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
try {
|
||||
const session = await ensureViewerSession()
|
||||
const category = displayCategories.value[clipCatIdx.value]
|
||||
const selectedCreator = category?.candidates[clipContext.selectedNomineeIndex]
|
||||
const selectedCreator = resolveSelectedCreator(category, clipContext.selectedNomineeQuery)
|
||||
if (category?.candidates.length && !selectedCreator) {
|
||||
formError.value = 'Bitte wähle einen VTuber aus den Vorschlägen aus.'
|
||||
return
|
||||
}
|
||||
|
||||
await store.submitClip({
|
||||
year: store.overview.year,
|
||||
categoryId: category ? Number(category.id) : null,
|
||||
@@ -204,6 +208,21 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSelectedCreator(category: HomeDisplayCategory | undefined, query: string) {
|
||||
const candidates = category?.candidates ?? []
|
||||
const normalizedQuery = normalizeSearchValue(query)
|
||||
if (!normalizedQuery) return null
|
||||
|
||||
return candidates.find((candidate) => normalizeSearchValue(candidate.displayName) === normalizedQuery)
|
||||
?? candidates.find((candidate) => normalizeSearchValue(candidate.channelSlug) === normalizedQuery)
|
||||
?? candidates.find((candidate) => normalizeSearchValue(candidate.displayName).includes(normalizedQuery))
|
||||
?? null
|
||||
}
|
||||
|
||||
function normalizeSearchValue(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useHomePhasePresentation(params: {
|
||||
showDate: ComputedRef<string>
|
||||
showStartsAt: ComputedRef<string>
|
||||
currentYear: ComputedRef<string>
|
||||
formatRange: (key: 'nomination' | 'voting' | 'review') => string
|
||||
formatRange: (key: 'nomination' | 'voting' | 'preparation') => string
|
||||
formatShowDate: () => string
|
||||
}) {
|
||||
const {
|
||||
@@ -27,7 +27,7 @@ export function useHomePhasePresentation(params: {
|
||||
|
||||
const nominationPhase = computed(() => previewPhase.value === 'nomination')
|
||||
const votingPhase = computed(() => previewPhase.value === 'voting')
|
||||
const reviewPhase = computed(() => previewPhase.value === 'review')
|
||||
const preparationPhase = computed(() => previewPhase.value === 'preparation')
|
||||
const showPhase = computed(() => previewPhase.value === 'show')
|
||||
const completedPhase = computed(() => previewPhase.value === 'completed')
|
||||
const currentTimestamp = ref(Date.now())
|
||||
@@ -39,13 +39,13 @@ export function useHomePhasePresentation(params: {
|
||||
const showStartMs = computed(() => parseShowStartMs(showDate.value, showStartsAt.value))
|
||||
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
|
||||
const streamLocked = computed(() => !streamLive.value)
|
||||
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : reviewPhase.value ? 'Review & Auswertung' : 'Award Show Live')
|
||||
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche deine Favoriten und Highlight-Clips ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : reviewPhase.value ? 'Das Voting ist abgeschlossen. Das Team prüft Ergebnisse, Clips und finale Show-Momente.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
|
||||
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : reviewPhase.value ? `Review-Zeitraum · ${formatRange('review')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
|
||||
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : reviewPhase.value ? 'IN PRÜFUNG' : 'AKTIV')
|
||||
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : reviewPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
|
||||
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : reviewPhase.value ? '✦ Auswertung läuft' : '● Zum Live-Stream')
|
||||
const phasePrimaryDisabled = computed(() => reviewPhase.value || completedPhase.value)
|
||||
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : preparationPhase.value ? 'Aufbereitung bis zur Show' : 'Award Show Live')
|
||||
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche deine Favoriten und Highlight-Clips ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
|
||||
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : preparationPhase.value ? `Aufbereitungszeit · ${formatRange('preparation')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
|
||||
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : preparationPhase.value ? 'IN AUFBEREITUNG' : 'AKTIV')
|
||||
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : preparationPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
|
||||
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : preparationPhase.value ? '✦ Show wird vorbereitet' : '● Zum Live-Stream')
|
||||
const phasePrimaryDisabled = computed(() => preparationPhase.value || completedPhase.value)
|
||||
const phasePrimaryActionStyle = computed(() => phasePrimaryDisabled.value
|
||||
? 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:#eee7f8;color:#9b8abf;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:none;cursor:not-allowed;'
|
||||
: 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:0 10px 22px rgba(124,86,196,.32);cursor:pointer;')
|
||||
@@ -68,14 +68,14 @@ export function useHomePhasePresentation(params: {
|
||||
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#e2d6f4 0%,#e2d6f4 100%);border-radius:3px;z-index:0;'
|
||||
: votingPhase.value
|
||||
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 33.33%,#e2d6f4 33.33%,#e2d6f4 100%);border-radius:3px;z-index:0;'
|
||||
: reviewPhase.value
|
||||
: preparationPhase.value
|
||||
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 66.66%,#e2d6f4 66.66%,#e2d6f4 100%);border-radius:3px;z-index:0;'
|
||||
: 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 100%,#e2d6f4 100%,#e2d6f4 100%);border-radius:3px;z-index:0;')
|
||||
const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : reviewPhase.value ? 'Das Voting wird gerade ausgewertet ✦' : 'Die Gewinner werden jetzt live gekürt ✦')
|
||||
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : reviewPhase.value ? 'Die Community hat abgestimmt. Jetzt prüft das Team Ergebnisse, Clips und finale Showeinspieler für die Award-Nacht.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
|
||||
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : reviewPhase.value ? 'Auswertung läuft' : 'Zum Live-Stream')
|
||||
const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : preparationPhase.value ? 'Die Show wird vorbereitet ✦' : 'Die Gewinner werden jetzt live gekürt ✦')
|
||||
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : preparationPhase.value ? 'Die Community hat abgestimmt. Jetzt bereitet das Team Clips, Ablauf und Show-Momente für die Award-Nacht vor.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
|
||||
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : preparationPhase.value ? 'Aufbereitung läuft' : 'Zum Live-Stream')
|
||||
const sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#')
|
||||
const sectionActionDisabled = computed(() => reviewPhase.value || completedPhase.value)
|
||||
const sectionActionDisabled = computed(() => preparationPhase.value || completedPhase.value)
|
||||
const sectionActionStyle = computed(() => sectionActionDisabled.value
|
||||
? 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:rgba(255,255,255,.72);color:#9b8abf;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:none;cursor:not-allowed;border:none;'
|
||||
: nominationPhase.value
|
||||
@@ -85,7 +85,7 @@ export function useHomePhasePresentation(params: {
|
||||
return {
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
preparationPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { simpleIconForKey } from '../../lib/socialIcons'
|
||||
import type { OverviewResponse } from '../../types/awards'
|
||||
|
||||
@@ -26,15 +27,10 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
|
||||
)
|
||||
|
||||
const footerLinks = computed(() =>
|
||||
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && link.url),
|
||||
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (link.url || link.content)),
|
||||
)
|
||||
|
||||
const privacyContentBlocks = computed(() =>
|
||||
(siteContent.value.privacyPolicyContent || '')
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
|
||||
|
||||
function platformKey(value: string | null | undefined) {
|
||||
return (value ?? 'link').trim().toLowerCase()
|
||||
@@ -56,7 +52,7 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
|
||||
hostSocialLinks,
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
privacyContentBlocks,
|
||||
privacyContentHtml,
|
||||
platformKey,
|
||||
isUploadedSocialIcon,
|
||||
socialSimpleIconPath,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { Eye, EyeOff } from '@lucide/vue'
|
||||
import { computed, ref, useAttrs } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
inputClass?: string
|
||||
rootClass?: string
|
||||
variant?: 'default' | 'login'
|
||||
}>(), {
|
||||
inputClass: '',
|
||||
rootClass: '',
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const visible = ref(false)
|
||||
|
||||
const inputType = computed(() => visible.value ? 'text' : 'password')
|
||||
const toggleLabel = computed(() => visible.value ? 'Passwort ausblenden' : 'Passwort anzeigen')
|
||||
const ToggleIcon = computed(() => visible.value ? EyeOff : Eye)
|
||||
|
||||
const disabled = computed(() =>
|
||||
attrs.disabled === true || attrs.disabled === '' || attrs.disabled === 'true',
|
||||
)
|
||||
|
||||
function updateValue(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
visible.value = !visible.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-field" :class="[`password-field--${variant}`, rootClass]">
|
||||
<input
|
||||
v-bind="attrs"
|
||||
:value="modelValue"
|
||||
:type="inputType"
|
||||
:class="['password-field__input', inputClass]"
|
||||
@input="updateValue"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="toggleLabel"
|
||||
:aria-pressed="visible"
|
||||
:disabled="disabled"
|
||||
@click="toggleVisibility"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<component :is="ToggleIcon" class="password-field__icon" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-field__input {
|
||||
width: 100%;
|
||||
padding-right: 3rem !important;
|
||||
}
|
||||
|
||||
.password-field__toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.62rem;
|
||||
display: inline-grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #7c3aed;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.password-field__toggle:hover:not(:disabled),
|
||||
.password-field__toggle:focus-visible {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #5b21b6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.password-field__toggle:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.password-field__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.password-field--login .password-field__input {
|
||||
box-sizing: border-box;
|
||||
border: 1.5px solid #ded4ff;
|
||||
border-radius: 22px;
|
||||
padding: 17px 54px 17px 18px !important;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: #3f3556;
|
||||
font: 700 17px 'Outfit', sans-serif;
|
||||
outline: none;
|
||||
box-shadow: 0 12px 32px rgba(139, 108, 219, 0.06);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.password-field--login .password-field__input:focus {
|
||||
border-color: #8b6cdb;
|
||||
box-shadow: 0 0 0 5px rgba(139, 108, 219, 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.password-field--login .password-field__toggle {
|
||||
right: 0.8rem;
|
||||
color: #7355c8;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user