Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,14 +60,14 @@ function initialsFor(value: string) {
|
||||
<article
|
||||
v-for="winner in props.selectedArchive.winners"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
||||
style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:15px;padding:16px 18px;border-radius:20px;background:rgba(255,255,255,.74);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
|
||||
style="display:grid;grid-template-columns:84px minmax(0,1fr);gap:16px;padding:18px;border-radius:24px;background:rgba(255,255,255,.78);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);align-items:start;"
|
||||
>
|
||||
<div style="width:76px;height:76px;border-radius:22px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:24px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
|
||||
<div style="width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffe1a3,#e7b13e);display:grid;place-items:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:26px;font-weight:800;box-shadow:0 12px 24px rgba(139,108,219,.16);">
|
||||
{{ initialsFor(winner.name) }}
|
||||
</div>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-size:10px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#d9942a;margin-bottom:7px;">{{ winner.category }}</div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:18px;line-height:1.25;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ winner.name }}</div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:20px;line-height:1.2;color:#3f3556;">{{ winner.name }}</div>
|
||||
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:9px;margin-top:12px;">
|
||||
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
|
||||
@@ -94,6 +94,26 @@ function initialsFor(value: string) {
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
style="width:100%;aspect-ratio:16/9;border:0;border-radius:14px;background:#1b0d2d;margin-top:12px;"
|
||||
/>
|
||||
<div
|
||||
v-else-if="winner.clipUrl"
|
||||
style="display:flex;align-items:center;justify-content:center;aspect-ratio:16/9;border-radius:14px;background:rgba(255,255,255,.88);border:1px solid rgba(139,108,219,.12);margin-top:12px;padding:14px;text-align:center;"
|
||||
>
|
||||
<a
|
||||
:href="winner.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
style="color:#7c5bb4;text-decoration:none;font-family:'Outfit',sans-serif;font-size:13px;font-weight:700;"
|
||||
>
|
||||
Clip extern ansehen
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
style="display:flex;align-items:center;justify-content:center;aspect-ratio:16/9;border-radius:14px;background:rgba(255,248,232,.92);border:1px dashed rgba(231,177,62,.42);margin-top:12px;padding:14px;text-align:center;color:#b7791f;font-family:'Outfit',sans-serif;font-size:13px;font-weight:700;"
|
||||
>
|
||||
Fuer diesen Gewinner ist kein Clip hinterlegt.
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -2,28 +2,33 @@
|
||||
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
||||
<div style="text-align:center;margin-bottom:52px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#ff5fa2);margin-bottom:12px;">✦ Die Awards</div>
|
||||
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Newcomer bis VTuber des Jahres — für jede Art von Magie gibt es einen Stern zu gewinnen.</p>
|
||||
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:0;">{{ landingCategories.length }} Hauptkategorien · 1 Sternenhimmel</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Gaming bis Creative - jede Hauptkategorie bringt ihre eigenen Viewer-Tiers und Favoriten mit.</p>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
|
||||
<div v-for="category in displayCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
|
||||
<div v-for="category in landingCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
|
||||
<div style="display:inline-flex;align-items:center;justify-content:center;width:50px;height:50px;border-radius:15px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:24px;margin-bottom:16px;box-shadow:0 8px 20px var(--glow,rgba(255,95,162,.4));">{{ category.icon }}</div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;margin:0 0 6px;">{{ category.name }}</h3>
|
||||
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.candidates.length }} Kandidat:innen aktuell in dieser Award-Kategorie.</p>
|
||||
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.subcategoryCount }} Viewer-Tiers · {{ category.candidateCount }} Kandidat:innen aktuell.</p>
|
||||
<p v-if="category.description" style="font-size:13px;color:var(--muted,#c9b8da);margin:12px 0 0;line-height:1.5;">{{ category.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type DisplayCategory = {
|
||||
type LandingCategory = {
|
||||
id: string
|
||||
groupName: string
|
||||
name: string
|
||||
icon: string
|
||||
candidates: unknown[]
|
||||
description: string
|
||||
maxNomineesPerUser: number
|
||||
candidateCount: number
|
||||
subcategoryCount: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
displayCategories: DisplayCategory[]
|
||||
landingCategories: LandingCategory[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface HomeCategoryProgressRailItem {
|
||||
id: string | number
|
||||
name: string
|
||||
groupName?: string
|
||||
icon: string
|
||||
status: string
|
||||
tone: 'empty' | 'active' | 'done' | 'error'
|
||||
@@ -13,25 +16,42 @@ const props = defineProps<{
|
||||
label: string
|
||||
onSelect: (id: string | number) => void
|
||||
}>()
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
const groups: Array<{ name: string; items: HomeCategoryProgressRailItem[] }> = []
|
||||
for (const item of props.items) {
|
||||
const groupName = item.groupName?.trim() || 'Weitere Kategorien'
|
||||
const current = groups.at(-1)
|
||||
if (current?.name === groupName) {
|
||||
current.items.push(item)
|
||||
} else {
|
||||
groups.push({ name: groupName, items: [item] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="home-wizard-rail">
|
||||
<div class="home-wizard-rail__label">{{ props.label }}</div>
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="home-wizard-rail__button"
|
||||
:class="[
|
||||
`home-wizard-rail__button--${item.tone}`,
|
||||
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
|
||||
]"
|
||||
@click="props.onSelect(item.id)"
|
||||
>
|
||||
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
|
||||
<span class="home-wizard-rail__name">{{ item.name }}</span>
|
||||
<span class="home-wizard-rail__status">{{ item.status }}</span>
|
||||
</button>
|
||||
<section v-for="group in groupedItems" :key="group.name" class="home-wizard-rail__group">
|
||||
<div class="home-wizard-rail__group-label">{{ group.name }}</div>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="home-wizard-rail__button"
|
||||
:class="[
|
||||
`home-wizard-rail__button--${item.tone}`,
|
||||
{ 'home-wizard-rail__button--selected': item.id === props.activeId },
|
||||
]"
|
||||
@click="props.onSelect(item.id)"
|
||||
>
|
||||
<span class="home-wizard-rail__icon">{{ item.icon }}</span>
|
||||
<span class="home-wizard-rail__name">{{ item.name }}</span>
|
||||
<span class="home-wizard-rail__status">{{ item.status }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,59 +1,408 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { CheckCircle2, Handshake, Mic2, Send } from '@lucide/vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Handshake, Mic2, Send, X } from '@lucide/vue'
|
||||
|
||||
import { privacyContentToHtml } from '../../lib/privacyContent'
|
||||
import { useAdminToast } from '../../composables/useAdminToast'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { ShowactFormField } from '../../types/awards/showactForm'
|
||||
import { DEFAULT_SHOWACT_SCHEMA } from '../../types/awards/showactForm'
|
||||
import type { CreateShowactApplicationPayload } from '../../types/awards'
|
||||
import HomeStarField from './HomeStarField.vue'
|
||||
|
||||
const props = defineProps<{ modalOpen?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modalOpen': [boolean] }>()
|
||||
|
||||
const { showAdminToast } = useAdminToast()
|
||||
const store = useAwardsStore()
|
||||
const submitting = ref(false)
|
||||
const submitMessage = ref('')
|
||||
const submitError = ref('')
|
||||
const form = reactive({
|
||||
artistName: '',
|
||||
contactEmail: '',
|
||||
contactDiscord: '',
|
||||
platformUrl: '',
|
||||
performanceType: '',
|
||||
description: '',
|
||||
technicalNotes: '',
|
||||
referenceUrl: '',
|
||||
})
|
||||
const localModalOpen = ref(false)
|
||||
const showactInfoModalOpen = ref(false)
|
||||
|
||||
const fieldValues = reactive<Record<string, string>>({})
|
||||
const fieldErrors = reactive<Record<string, string>>({})
|
||||
|
||||
const featureFlags = computed(() => store.overview.featureFlags)
|
||||
const sponsors = computed(() =>
|
||||
featureFlags.value.sponsorsVisible ? store.publicSponsors.items.filter((item) => item.isVisible) : [],
|
||||
)
|
||||
const showSection = computed(() => sponsors.value.length > 0 || featureFlags.value.showactApplicationsEnabled)
|
||||
const showactFooterLink = computed(() =>
|
||||
(store.overview.siteContent.footerLinks ?? []).find((link) => link.key === 'showacts') ?? null,
|
||||
)
|
||||
const showactInfoHtml = computed(() => privacyContentToHtml(showactFooterLink.value?.content || ''))
|
||||
const showactHasInfo = computed(() =>
|
||||
Boolean(
|
||||
showactFooterLink.value?.content?.trim()
|
||||
|| (showactFooterLink.value?.url && isSafePublicUrl(showactFooterLink.value.url))
|
||||
|| featureFlags.value.showactApplicationDisabledMessage?.trim()
|
||||
|| featureFlags.value.showactApplicationStartsAt
|
||||
|| featureFlags.value.showactApplicationEndsAt,
|
||||
),
|
||||
)
|
||||
const showactVisible = computed(() => featureFlags.value.showactApplicationsEnabled || showactHasInfo.value)
|
||||
const showSection = computed(() => sponsors.value.length > 0 || showactVisible.value)
|
||||
const showactDeadlineText = computed(() => {
|
||||
const deadline = featureFlags.value.showactApplicationEndsAt
|
||||
if (!deadline) return ''
|
||||
|
||||
const parsed = new Date(`${deadline}T00:00:00`)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return `Bewerbung bis ${deadline}`
|
||||
}
|
||||
|
||||
return `Bewerbung bis ${parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`
|
||||
})
|
||||
const showactStatusText = computed(() => {
|
||||
if (featureFlags.value.showactApplicationsEnabled) {
|
||||
return 'Bewerbungen sind aktuell offen.'
|
||||
}
|
||||
|
||||
return featureFlags.value.showactApplicationDisabledMessage?.trim()
|
||||
|| 'Showact-Bewerbungen sind aktuell geschlossen.'
|
||||
})
|
||||
const showactPrimaryLabel = computed(() =>
|
||||
featureFlags.value.showactApplicationsEnabled ? 'Jetzt bewerben' : 'Mehr erfahren',
|
||||
)
|
||||
const showactPrimaryHref = computed(() =>
|
||||
!featureFlags.value.showactApplicationsEnabled && isSafePublicUrl(showactFooterLink.value?.url)
|
||||
? showactFooterLink.value?.url ?? ''
|
||||
: '',
|
||||
)
|
||||
const showactPrimaryDisabled = computed(() =>
|
||||
!featureFlags.value.showactApplicationsEnabled
|
||||
&& !showactInfoHtml.value
|
||||
&& !showactPrimaryHref.value,
|
||||
)
|
||||
const showactPrimaryButtonStyle = computed(() =>
|
||||
showactPrimaryDisabled.value
|
||||
? "display:inline-flex;align-items:center;gap:9px;height:44px;padding:0 20px;border-radius:14px;background:#c9b8da;border:none;color:#fff;font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;cursor:not-allowed;box-shadow:none;transition:transform .15s,box-shadow .15s;flex-shrink:0;opacity:.86;"
|
||||
: "display:inline-flex;align-items:center;gap:9px;height:44px;padding:0 20px;border-radius:14px;background:#8b6cdb;border:none;color:#fff;font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;cursor:pointer;box-shadow:0 4px 14px rgba(124,86,196,.25);transition:transform .15s,box-shadow .15s;flex-shrink:0;",
|
||||
)
|
||||
|
||||
const showactSchema = computed((): ShowactFormField[] => {
|
||||
const json = featureFlags.value.showactFormSchemaJson ?? '[]'
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed as ShowactFormField[]
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return DEFAULT_SHOWACT_SCHEMA
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
form.artistName = ''
|
||||
form.contactEmail = ''
|
||||
form.contactDiscord = ''
|
||||
form.platformUrl = ''
|
||||
form.performanceType = ''
|
||||
form.description = ''
|
||||
form.technicalNotes = ''
|
||||
form.referenceUrl = ''
|
||||
for (const key of Object.keys(fieldValues)) {
|
||||
delete fieldValues[key]
|
||||
}
|
||||
for (const key of Object.keys(fieldErrors)) {
|
||||
delete fieldErrors[key]
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
localModalOpen.value = false
|
||||
emit('update:modalOpen', false)
|
||||
}
|
||||
|
||||
function openShowactPrimaryAction() {
|
||||
if (featureFlags.value.showactApplicationsEnabled) {
|
||||
localModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (showactInfoHtml.value) {
|
||||
showactInfoModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackUrl = showactPrimaryHref.value
|
||||
if (fallbackUrl && typeof window !== 'undefined') {
|
||||
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
function closeShowactInfoModal() {
|
||||
showactInfoModalOpen.value = false
|
||||
emit('update:modalOpen', false)
|
||||
}
|
||||
|
||||
// For multiselect: values are stored as comma-separated strings
|
||||
function getMultiselectValues(fieldId: string): string[] {
|
||||
const raw = fieldValues[fieldId] ?? ''
|
||||
return raw ? raw.split(',').map((v) => v.trim()).filter(Boolean) : []
|
||||
}
|
||||
|
||||
function otherFieldId(fieldId: string) {
|
||||
return `${fieldId}__other`
|
||||
}
|
||||
|
||||
function getOtherValue(fieldId: string) {
|
||||
return (fieldValues[otherFieldId(fieldId)] ?? '').trim()
|
||||
}
|
||||
|
||||
function setMultiselectValue(fieldId: string, option: string, checked: boolean) {
|
||||
const current = getMultiselectValues(fieldId)
|
||||
if (checked) {
|
||||
if (!current.includes(option)) current.push(option)
|
||||
} else {
|
||||
const idx = current.indexOf(option)
|
||||
if (idx !== -1) current.splice(idx, 1)
|
||||
}
|
||||
fieldValues[fieldId] = current.join(', ')
|
||||
delete fieldErrors[fieldId]
|
||||
}
|
||||
|
||||
function setCheckboxValue(field: ShowactFormField, checked: boolean) {
|
||||
const value = field.options[0] || 'Ja'
|
||||
fieldValues[field.id] = checked ? value : ''
|
||||
delete fieldErrors[field.id]
|
||||
}
|
||||
|
||||
function isCheckboxChecked(field: ShowactFormField) {
|
||||
return Boolean((fieldValues[field.id] ?? '').trim())
|
||||
}
|
||||
|
||||
function updateOtherValue(field: ShowactFormField, value: string) {
|
||||
fieldValues[otherFieldId(field.id)] = value
|
||||
delete fieldErrors[field.id]
|
||||
}
|
||||
|
||||
function validateField(field: ShowactFormField): string {
|
||||
const value = (fieldValues[field.id] ?? '').trim()
|
||||
const otherValue = field.allowOther ? getOtherValue(field.id) : ''
|
||||
|
||||
if (field.required && !value && !otherValue) {
|
||||
if (field.type === 'checkbox') {
|
||||
return `Bitte bestaetige „${field.label}".`
|
||||
}
|
||||
|
||||
if (field.type === 'radio' || field.type === 'select' || field.type === 'multiselect') {
|
||||
return `Bitte waehle bei „${field.label}" eine Antwort aus.`
|
||||
}
|
||||
|
||||
return `Bitte fuell das Feld „${field.label}" aus.`
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (field.type === 'email') {
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailPattern.test(value)) {
|
||||
return `Bitte gib bei „${field.label}" eine gueltige E-Mail-Adresse ein.`
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'url') {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return `Bitte gib bei „${field.label}" einen gueltigen Link mit http oder https ein.`
|
||||
}
|
||||
} catch {
|
||||
return `Bitte gib bei „${field.label}" einen gueltigen Link ein.`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function updateFieldError(field: ShowactFormField) {
|
||||
const message = validateField(field)
|
||||
if (message) {
|
||||
fieldErrors[field.id] = message
|
||||
return
|
||||
}
|
||||
|
||||
delete fieldErrors[field.id]
|
||||
}
|
||||
|
||||
function resolveFieldValue(field?: ShowactFormField | null, source: Record<string, string> = fieldValues) {
|
||||
if (!field) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (source[field.id] ?? '').trim()
|
||||
}
|
||||
|
||||
function findFieldByMatcher(matcher: (field: ShowactFormField) => boolean) {
|
||||
return showactSchema.value.find(matcher) ?? null
|
||||
}
|
||||
|
||||
function fieldHaystack(field: ShowactFormField) {
|
||||
return `${field.id} ${field.label}`.toLowerCase()
|
||||
}
|
||||
|
||||
function fieldMatches(field: ShowactFormField, needles: string[]) {
|
||||
const haystack = fieldHaystack(field)
|
||||
return needles.some((needle) => haystack.includes(needle))
|
||||
}
|
||||
|
||||
function findFieldByIdOrLabel(ids: string[], labels: string[], type?: ShowactFormField['type']) {
|
||||
return findFieldByMatcher((field) => {
|
||||
if (type && field.type !== type) return false
|
||||
const id = field.id.toLowerCase()
|
||||
const label = field.label.toLowerCase()
|
||||
return ids.some((needle) => id.includes(needle)) || labels.some((needle) => label.includes(needle))
|
||||
})
|
||||
}
|
||||
|
||||
function buildShowactPayload(fieldResponsesJson: string, responses: Record<string, string>): CreateShowactApplicationPayload {
|
||||
const artistField = findFieldByMatcher((field) => field.isArtistName)
|
||||
const emailField = findFieldByMatcher((field) => field.type === 'email')
|
||||
const discordField = findFieldByMatcher((field) => fieldMatches(field, ['discord']))
|
||||
const platformField = findFieldByMatcher((field) => {
|
||||
if (field.type !== 'url') return false
|
||||
return fieldMatches(field, ['platform', 'kanal', 'profil', 'channel'])
|
||||
})
|
||||
const referenceField = findFieldByMatcher((field) => {
|
||||
if (field.type !== 'url') return false
|
||||
return fieldMatches(field, ['referenz', 'reference'])
|
||||
})
|
||||
const performanceField = findFieldByIdOrLabel(
|
||||
['performance_type', 'showact_type', 'show_type', 'showact_roles', 'roles'],
|
||||
['showact-art', 'showact art', 'art des showacts', 'performance', 'wofür möchtest', 'wofuer moechtest', 'bewerben'],
|
||||
)
|
||||
const descriptionField = findFieldByIdOrLabel(
|
||||
['description', 'beschreibung', 'show_description'],
|
||||
['beschreibung', 'idee', 'was moechtest', 'was möchtest', 'zeigen'],
|
||||
'textarea',
|
||||
)
|
||||
const technicalNotesField = findFieldByIdOrLabel(
|
||||
['technical_notes', 'technik', 'technical', 'setup', 'timing'],
|
||||
['technik', 'technical', 'setup', 'timing'],
|
||||
)
|
||||
|
||||
const textareaSummary = showactSchema.value
|
||||
.filter((field) => field.type === 'textarea')
|
||||
.map((field) => resolveFieldValue(field, responses))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
const description = resolveFieldValue(descriptionField, responses) || textareaSummary || 'Strukturierte Showact-Bewerbung'
|
||||
|
||||
return {
|
||||
artistName: resolveFieldValue(artistField, responses),
|
||||
contactEmail: resolveFieldValue(emailField, responses),
|
||||
contactDiscord: resolveFieldValue(discordField, responses),
|
||||
platformUrl: resolveFieldValue(platformField, responses),
|
||||
performanceType: resolveFieldValue(performanceField, responses),
|
||||
description,
|
||||
technicalNotes: resolveFieldValue(technicalNotesField, responses),
|
||||
referenceUrl: resolveFieldValue(referenceField, responses),
|
||||
fieldResponsesJson,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShowact() {
|
||||
for (const field of showactSchema.value) {
|
||||
const message = validateField(field)
|
||||
if (message) {
|
||||
fieldErrors[field.id] = message
|
||||
showAdminToast(message, 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
const responses = { ...fieldValues }
|
||||
for (const field of showactSchema.value) {
|
||||
if (!field.allowOther) {
|
||||
continue
|
||||
}
|
||||
|
||||
const otherValue = getOtherValue(field.id)
|
||||
delete responses[otherFieldId(field.id)]
|
||||
if (otherValue) {
|
||||
const current = getMultiselectValues(field.id)
|
||||
responses[field.id] = [...current, `Andere: ${otherValue}`].join(', ')
|
||||
}
|
||||
}
|
||||
const fieldResponsesJson = JSON.stringify(responses)
|
||||
submitting.value = true
|
||||
submitMessage.value = ''
|
||||
submitError.value = ''
|
||||
try {
|
||||
await store.submitShowactApplication(form)
|
||||
submitMessage.value = 'Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.'
|
||||
await store.submitShowactApplication(buildShowactPayload(fieldResponsesJson, responses))
|
||||
showAdminToast('Bewerbung ist angekommen. Das Team meldet sich, wenn es passt.', 'success')
|
||||
resetForm()
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.'
|
||||
showAdminToast(error instanceof Error ? error.message : 'Bewerbung konnte nicht gesendet werden.', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = 'padding:13px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:\'Outfit\',sans-serif;font-size:15px;color:#3f3556;outline:none;transition:border-color .15s;'
|
||||
const labelStyle = 'font-family:\'Outfit\',sans-serif;font-size:13px;font-weight:600;color:#5f44ad;'
|
||||
const errorStyle = 'font-family:\'Outfit\',sans-serif;font-size:12px;font-weight:600;color:#d14b72;line-height:1.45;'
|
||||
const checkboxStyle = 'display:flex;align-items:center;gap:9px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:\'Outfit\',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;'
|
||||
|
||||
function onFocus(event: FocusEvent) {
|
||||
;(event.target as HTMLElement).style.borderColor = '#8b6cdb'
|
||||
}
|
||||
function onBlur(event: FocusEvent) {
|
||||
;(event.target as HTMLElement).style.borderColor = '#e6dcf6'
|
||||
}
|
||||
|
||||
function handleFieldBlur(field: ShowactFormField, event: FocusEvent) {
|
||||
onBlur(event)
|
||||
updateFieldError(field)
|
||||
}
|
||||
|
||||
function resolveTextInputType(field: ShowactFormField) {
|
||||
if (field.type === 'email' || field.type === 'url') {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function resolveInputMode(field: ShowactFormField) {
|
||||
if (field.type === 'email') return 'email'
|
||||
if (field.type === 'url') return 'url'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function resolveAutoComplete(field: ShowactFormField) {
|
||||
if (field.type === 'email') return 'email'
|
||||
if (field.type === 'url') return 'url'
|
||||
return 'off'
|
||||
}
|
||||
|
||||
function isSafePublicUrl(value: string | null | undefined) {
|
||||
if (!value) return false
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modalOpen,
|
||||
(next) => {
|
||||
if (!next) {
|
||||
localModalOpen.value = false
|
||||
showactInfoModalOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (featureFlags.value.showactApplicationsEnabled) {
|
||||
localModalOpen.value = true
|
||||
showactInfoModalOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
localModalOpen.value = false
|
||||
showactInfoModalOpen.value = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="showSection" class="mx-auto grid w-full max-w-6xl gap-5 px-5 py-10 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)]">
|
||||
<section v-if="showSection" class="mx-auto w-full max-w-[1200px] space-y-5 px-6 pb-6 -mt-10">
|
||||
<!-- Sponsor-Kacheln -->
|
||||
<div v-if="sponsors.length > 0" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-sky-100 text-sky-700">
|
||||
@@ -64,8 +413,7 @@ async function submitShowact() {
|
||||
<h2 class="text-xl font-black text-[#3f3556]">Sponsoren</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
v-for="sponsor in sponsors"
|
||||
:key="sponsor.id"
|
||||
@@ -89,48 +437,296 @@ async function submitShowact() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="featureFlags.showactApplicationsEnabled" class="rounded-[24px] border border-white/70 bg-white/78 p-5 shadow-[0_18px_46px_rgba(105,78,160,0.13)] backdrop-blur">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-11 w-11 place-items-center rounded-2xl bg-fuchsia-100 text-fuchsia-700">
|
||||
<Mic2 class="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.2em] text-fuchsia-600">Showacts</p>
|
||||
<h2 class="text-xl font-black text-[#3f3556]">Bewerben</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mt-5 space-y-3" @submit.prevent="submitShowact">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input v-model="form.artistName" required maxlength="120" placeholder="Kuenstlername" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="form.performanceType" required maxlength="80" placeholder="Showact-Art" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input v-model="form.contactEmail" maxlength="180" placeholder="E-Mail" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="form.contactDiscord" maxlength="120" placeholder="Discord" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<input v-model="form.platformUrl" maxlength="500" placeholder="Kanal/Profil URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="form.referenceUrl" maxlength="500" placeholder="Referenz URL" class="rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
<textarea v-model="form.description" required rows="3" maxlength="1000" placeholder="Kurz beschreiben, was du zeigen moechtest" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
<textarea v-model="form.technicalNotes" rows="2" maxlength="1000" placeholder="Technische Hinweise, Setup, Timing" class="w-full rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm outline-none focus:border-violet-300 focus:ring-4 focus:ring-violet-100" />
|
||||
|
||||
<p v-if="submitError" class="rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ submitError }}</p>
|
||||
<p v-if="submitMessage" class="flex items-center gap-2 rounded-2xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
{{ submitMessage }}
|
||||
<!-- Showact-Banner -->
|
||||
<div
|
||||
v-if="showactVisible"
|
||||
class="flex flex-wrap items-center gap-5 rounded-[24px] border border-white/70 bg-white/78 px-6 py-5 backdrop-blur sm:flex-nowrap"
|
||||
>
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Mic2 class="h-6 w-6" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.2em] text-violet-500">Showacts</p>
|
||||
<h2 class="mt-0.5 text-xl font-black text-[#3f3556]">Als Showact auftreten</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-[#746b86]">
|
||||
<template v-if="featureFlags.showactApplicationsEnabled">
|
||||
Hast du eine Idee fuer einen Beitrag? Bewirb dich jetzt und zeig der Community, was du kannst.
|
||||
</template>
|
||||
<template v-else>
|
||||
Showacts bleiben ein Teil der Award-Show. Aktuell ist das Bewerbungsfenster geschlossen, aber alle Infos bleiben hier erreichbar.
|
||||
</template>
|
||||
<span v-if="showactDeadlineText" class="font-semibold text-violet-700">{{ showactDeadlineText }}</span>
|
||||
<span v-else class="font-semibold text-violet-700">{{ showactStatusText }}</span>
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-fuchsia-500 bg-fuchsia-600 px-5 text-sm font-black text-white shadow-[0_14px_28px_rgba(192,38,211,0.2)] transition hover:bg-fuchsia-500 disabled:opacity-60"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<a
|
||||
v-if="showactPrimaryHref"
|
||||
:href="showactPrimaryHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
style="display:inline-flex;align-items:center;gap:9px;height:44px;padding:0 20px;border-radius:14px;background:#8b6cdb;border:none;color:#fff;font-family:'Outfit',sans-serif;font-weight:700;font-size:14px;cursor:pointer;box-shadow:0 4px 14px rgba(124,86,196,.25);transition:transform .15s,box-shadow .15s;flex-shrink:0;text-decoration:none;"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
{{ showactPrimaryLabel }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:disabled="showactPrimaryDisabled"
|
||||
:style="showactPrimaryButtonStyle"
|
||||
@click="openShowactPrimaryAction"
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
{{ showactPrimaryLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bewerbungs-Modal im Landing-Page-Stil -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="localModalOpen"
|
||||
style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div
|
||||
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);"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Schliessen-Button -->
|
||||
<button
|
||||
aria-label="Schliessen"
|
||||
style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(255,255,255,.14);color:rgba(255,255,255,.8);font-size:18px;cursor:pointer;"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- Header mit Sternanimation -->
|
||||
<div style="position:relative;overflow:hidden;padding:30px 36px 26px;border-bottom:1px solid rgba(255,255,255,.12);background:linear-gradient(135deg,#2a1842,#3a2168);flex:none;">
|
||||
<HomeStarField :count="13" variant="header" />
|
||||
<div style="position:relative;z-index:1;">
|
||||
<div style="display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;background:rgba(201,177,255,.12);border:1px solid rgba(201,177,255,.34);color:#c9b1ff;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;margin-bottom:12px;">✦ Showacts</div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(26px,3vw,34px);line-height:1.1;margin:0 0 8px;color:#fff6fb;">Showact-Bewerbung</h3>
|
||||
<p style="font-size:15px;line-height:1.55;color:#d8c9eb;margin:0;">Fuell das Formular aus und schick deine Bewerbung ab.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamisches Formular -->
|
||||
<div style="overflow-y:auto;padding:28px 36px 32px;flex:1;">
|
||||
<form novalidate @submit.prevent="submitShowact">
|
||||
<template v-for="field in showactSchema" :key="field.id">
|
||||
<!-- text / email / url -->
|
||||
<label v-if="field.type === 'text' || field.type === 'email' || field.type === 'url'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<input
|
||||
v-model="fieldValues[field.id]"
|
||||
:type="resolveTextInputType(field)"
|
||||
:inputmode="resolveInputMode(field)"
|
||||
:autocomplete="resolveAutoComplete(field)"
|
||||
:maxlength="field.maxLength"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="fieldErrors[field.id] ? 'true' : 'false'"
|
||||
:style="inputStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')"
|
||||
@focus="onFocus"
|
||||
@blur="handleFieldBlur(field, $event)"
|
||||
@input="delete fieldErrors[field.id]"
|
||||
/>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</label>
|
||||
|
||||
<!-- textarea -->
|
||||
<label v-else-if="field.type === 'textarea'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<textarea
|
||||
v-model="fieldValues[field.id]"
|
||||
rows="4"
|
||||
:maxlength="field.maxLength"
|
||||
:placeholder="field.placeholder"
|
||||
:aria-invalid="fieldErrors[field.id] ? 'true' : 'false'"
|
||||
:style="inputStyle + 'resize:vertical;line-height:1.55;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')"
|
||||
@focus="onFocus"
|
||||
@blur="handleFieldBlur(field, $event)"
|
||||
@input="delete fieldErrors[field.id]"
|
||||
/>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</label>
|
||||
|
||||
<!-- select -->
|
||||
<label v-else-if="field.type === 'select'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<select
|
||||
v-model="fieldValues[field.id]"
|
||||
:aria-invalid="fieldErrors[field.id] ? 'true' : 'false'"
|
||||
:style="inputStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')"
|
||||
@focus="onFocus"
|
||||
@blur="handleFieldBlur(field, $event)"
|
||||
@change="delete fieldErrors[field.id]"
|
||||
>
|
||||
<option value="">{{ field.placeholder || '— Bitte wählen —' }}</option>
|
||||
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</label>
|
||||
|
||||
<!-- radio -->
|
||||
<div v-else-if="field.type === 'radio'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<div :style="'display:flex;flex-direction:column;gap:8px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
|
||||
<label
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
|
||||
>
|
||||
<input
|
||||
v-model="fieldValues[field.id]"
|
||||
type="radio"
|
||||
:name="field.id"
|
||||
:value="opt"
|
||||
style="width:17px;height:17px;accent-color:#7c56c4;"
|
||||
@change="delete fieldErrors[field.id]"
|
||||
/>
|
||||
{{ opt }}
|
||||
</label>
|
||||
</div>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- multiselect (checkboxes) -->
|
||||
<div v-else-if="field.type === 'multiselect'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<div :style="'display:flex;flex-direction:column;gap:6px;padding:10px 14px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;' + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
|
||||
<label
|
||||
v-for="opt in field.options"
|
||||
:key="opt"
|
||||
style="display:flex;align-items:center;gap:8px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="getMultiselectValues(field.id).includes(opt)"
|
||||
style="width:16px;height:16px;"
|
||||
@change="setMultiselectValue(field.id, opt, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
|
||||
/>
|
||||
{{ opt }}
|
||||
</label>
|
||||
<label
|
||||
v-if="field.allowOther"
|
||||
style="display:flex;align-items:center;gap:8px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;cursor:pointer;"
|
||||
>
|
||||
<span style="width:16px;height:16px;border-radius:4px;border:1.5px solid #d8c9eb;background:#fff;"></span>
|
||||
Andere:
|
||||
<input
|
||||
:value="getOtherValue(field.id)"
|
||||
type="text"
|
||||
placeholder="Eigene Antwort"
|
||||
style="min-width:0;flex:1;border:0;border-bottom:1.5px solid #d8c9eb;background:transparent;padding:4px 2px;font-family:'Outfit',sans-serif;font-size:14px;color:#3f3556;outline:none;"
|
||||
@input="updateOtherValue(field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span v-if="field.options.length === 0" style="font-size:13px;color:#9ca3af;">Keine Optionen konfiguriert.</span>
|
||||
</div>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- single checkbox confirmation -->
|
||||
<div v-else-if="field.type === 'checkbox'" style="display:flex;flex-direction:column;gap:6px;margin-bottom:14px;">
|
||||
<span :style="labelStyle">{{ field.label }}{{ field.required ? ' *' : '' }}</span>
|
||||
<span v-if="field.helpText" style="font-family:'Outfit',sans-serif;font-size:12px;line-height:1.45;color:#8a819d;">{{ field.helpText }}</span>
|
||||
<label :style="checkboxStyle + (fieldErrors[field.id] ? 'border-color:#d14b72;background:#fff7fa;' : '')">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isCheckboxChecked(field)"
|
||||
style="width:17px;height:17px;accent-color:#7c56c4;"
|
||||
@change="setCheckboxValue(field, ($event.target as HTMLInputElement).checked); updateFieldError(field)"
|
||||
/>
|
||||
{{ field.options[0] || 'Ja' }}
|
||||
</label>
|
||||
<span v-if="fieldErrors[field.id]" :style="errorStyle">{{ fieldErrors[field.id] }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:6px;">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
style="display:inline-flex;align-items:center;gap:8px;padding:14px 28px;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);transition:transform .15s,opacity .15s;"
|
||||
@mouseover="($event.currentTarget as HTMLButtonElement).style.transform='translateY(-2px)'"
|
||||
@mouseleave="($event.currentTarget as HTMLButtonElement).style.transform='translateY(0)'"
|
||||
>
|
||||
<Send :size="16" />
|
||||
{{ submitting ? 'Sendet ...' : 'Bewerbung senden' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showactInfoModalOpen"
|
||||
style="position:fixed;inset:0;z-index:205;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
@click.self="closeShowactInfoModal"
|
||||
>
|
||||
<div
|
||||
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);"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
aria-label="Schliessen"
|
||||
style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;"
|
||||
@click="closeShowactInfoModal"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
<div style="padding:30px 36px 20px;border-bottom:1px solid #f1ecfb;background:linear-gradient(135deg,#2a1842,#3a2168);">
|
||||
<p style="margin:0 0 8px;font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#c9b1ff;">Showacts</p>
|
||||
<h3 style="margin:0;font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;line-height:1.1;color:#fff6fb;">Infos zur Award-Show</h3>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 36px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||
<div v-if="showactInfoHtml" class="home-showact-info-content" v-html="showactInfoHtml" />
|
||||
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
|
||||
Fuer Showacts ist aktuell noch kein Landingpage-Inhalt hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-showact-info-content :deep(p) {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.home-showact-info-content :deep(p:last-child),
|
||||
.home-showact-info-content :deep(ul:last-child),
|
||||
.home-showact-info-content :deep(ol:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-showact-info-content :deep(ul),
|
||||
.home-showact-info-content :deep(ol) {
|
||||
margin: 0 0 14px 20px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.home-showact-info-content :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 560px) {
|
||||
.home-showact-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,16 +102,17 @@ function syncNominationDrafts(options: HomeSelectionOption[]) {
|
||||
const nextVisibleLinkCounts: Record<number, number> = {}
|
||||
for (const option of options) {
|
||||
const existing = nominationDrafts.value[option.id] ?? []
|
||||
nextDrafts[option.id] = [existing[0] ?? '', existing[1] ?? '', existing[2] ?? '']
|
||||
const limit = nominationLinkLimitFor(option.id)
|
||||
nextDrafts[option.id] = createNominationLinkSlots(existing, limit)
|
||||
const filledCount = nextDrafts[option.id].reduce((count, link, index) => link.trim() ? index + 1 : count, 0)
|
||||
nextVisibleLinkCounts[option.id] = Math.min(3, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
|
||||
nextVisibleLinkCounts[option.id] = Math.min(limit, Math.max(1, nominationVisibleLinkCounts.value[option.id] ?? filledCount))
|
||||
}
|
||||
nominationDrafts.value = nextDrafts
|
||||
nominationVisibleLinkCounts.value = nextVisibleLinkCounts
|
||||
}
|
||||
|
||||
function nominationLinksFor(categoryIndex: number) {
|
||||
return nominationDrafts.value[categoryIndex] ?? ['', '', '']
|
||||
return nominationDrafts.value[categoryIndex] ?? createNominationLinkSlots([], nominationLinkLimitFor(categoryIndex))
|
||||
}
|
||||
|
||||
function nominationVisibleLinkCountFor(categoryIndex: number) {
|
||||
@@ -119,9 +120,10 @@ function nominationVisibleLinkCountFor(categoryIndex: number) {
|
||||
}
|
||||
|
||||
function compactNominationCategory(categoryIndex: number) {
|
||||
const limit = nominationLinkLimitFor(categoryIndex)
|
||||
const links = nominationLinksFor(categoryIndex)
|
||||
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, 3)
|
||||
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
|
||||
const compactedLinks = links.map((link) => link.trim()).filter(Boolean).slice(0, limit)
|
||||
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
|
||||
const nextVisibleCount = Math.max(1, compactedLinks.length)
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
@@ -162,12 +164,13 @@ const activeNominationCategory = computed(() =>
|
||||
const activeNominationLinks = computed(() => nominationLinksFor(nominationCatValue.value))
|
||||
const activeNominationErrors = computed(() => nominationFieldErrors(nominationCatValue.value))
|
||||
const activeNominationVisibleLinkCount = computed(() => nominationVisibleLinkCountFor(nominationCatValue.value))
|
||||
const activeNominationRemainingLinks = computed(() => Math.max(0, 3 - activeNominationVisibleLinkCount.value))
|
||||
const activeNominationLinkLimit = computed(() => nominationLinkLimitFor(nominationCatValue.value))
|
||||
const activeNominationRemainingLinks = computed(() => Math.max(0, activeNominationLinkLimit.value - activeNominationVisibleLinkCount.value))
|
||||
const nominationReviewItems = computed(() =>
|
||||
props.catOptions
|
||||
.map((option) => ({
|
||||
categoryIndex: option.id,
|
||||
categoryName: stripCategoryIcon(option.label),
|
||||
categoryName: option.name || stripCategoryIcon(option.label),
|
||||
links: nominationLinksFor(option.id).map((link) => link.trim()).filter(Boolean),
|
||||
}))
|
||||
.filter((item) => item.links.length > 0),
|
||||
@@ -184,8 +187,9 @@ const nominationRailItems = computed(() => props.catOptions.map((option) => {
|
||||
const isActive = option.id === nominationCatValue.value
|
||||
return {
|
||||
id: option.id,
|
||||
name: stripCategoryIcon(option.label),
|
||||
icon: option.label.trim().split(' ')[0] ?? '✦',
|
||||
name: option.name || stripCategoryIcon(option.label),
|
||||
groupName: option.groupName,
|
||||
icon: option.icon || option.label.trim().split(' ')[0] || '✦',
|
||||
status: errors.some(Boolean) ? 'Fehler' : linkCount === 0 ? 'Leer' : `${linkCount} Link${linkCount === 1 ? '' : 's'}`,
|
||||
tone: errors.some(Boolean) ? 'error' as const : linkCount > 0 ? 'done' as const : isActive ? 'active' as const : 'empty' as const,
|
||||
}
|
||||
@@ -237,9 +241,10 @@ function editNominationCategory(categoryIndex: number) {
|
||||
|
||||
function addNominationLinkField() {
|
||||
const currentCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
nominationVisibleLinkCounts.value = {
|
||||
...nominationVisibleLinkCounts.value,
|
||||
[nominationCatValue.value]: Math.min(3, currentCount + 1),
|
||||
[nominationCatValue.value]: Math.min(limit, currentCount + 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +254,11 @@ function removeNominationLinkField(index: number) {
|
||||
.filter((_, linkIndex) => linkIndex !== index)
|
||||
.map((link) => link.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
const nextLinks = [compactedLinks[0] ?? '', compactedLinks[1] ?? '', compactedLinks[2] ?? '']
|
||||
.slice(0, nominationLinkLimitFor(nominationCatValue.value))
|
||||
const limit = nominationLinkLimitFor(nominationCatValue.value)
|
||||
const nextLinks = createNominationLinkSlots(compactedLinks, limit)
|
||||
const previousVisibleCount = nominationVisibleLinkCountFor(nominationCatValue.value)
|
||||
const nextVisibleCount = Math.max(1, Math.min(3, Math.max(previousVisibleCount - 1, compactedLinks.length)))
|
||||
const nextVisibleCount = Math.max(1, Math.min(limit, Math.max(previousVisibleCount - 1, compactedLinks.length)))
|
||||
nominationDrafts.value = {
|
||||
...nominationDrafts.value,
|
||||
[nominationCatValue.value]: nextLinks,
|
||||
@@ -286,6 +292,16 @@ function normalizeUrlForCompare(value: string) {
|
||||
return value.trim().replace(/\/+$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
function nominationLinkLimitFor(categoryIndex: number) {
|
||||
const rawLimit = props.catOptions.find((option) => option.id === categoryIndex)?.maxNomineesPerUser
|
||||
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) ? rawLimit : 3
|
||||
return Math.max(1, Math.min(10, Math.round(limit)))
|
||||
}
|
||||
|
||||
function createNominationLinkSlots(existing: string[], limit: number) {
|
||||
return Array.from({ length: limit }, (_, index) => existing[index] ?? '')
|
||||
}
|
||||
|
||||
function stripCategoryIcon(value: string) {
|
||||
return value.replace(/^\S+\s+/, '')
|
||||
}
|
||||
@@ -294,7 +310,7 @@ function stripCategoryIcon(value: string) {
|
||||
<template>
|
||||
<template v-if="props.modalOpen">
|
||||
<div class="home-modal-overlay" @click="props.closeModal" style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
||||
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.isVote && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;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 class="home-modal" :class="{ 'home-modal--wide': props.isPicker, 'home-modal--voting': props.isPicker && props.notSubmitted }" @click="props.stop" style="position:relative;width:100%;max-width:680px;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);">
|
||||
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);">✕</button>
|
||||
|
||||
<template v-if="props.submitted">
|
||||
@@ -309,13 +325,6 @@ function stripCategoryIcon(value: string) {
|
||||
</template>
|
||||
|
||||
<template v-if="props.notSubmitted">
|
||||
<p
|
||||
v-if="props.formError"
|
||||
style="margin:18px 36px 0;padding:12px 14px;border-radius:12px;border:1px solid #fecdd3;background:#fff1f2;color:#be123c;font-size:13.5px;font-weight:600;line-height:1.45;"
|
||||
>
|
||||
{{ props.formError }}
|
||||
</p>
|
||||
|
||||
<template v-if="props.isShow">
|
||||
<div class="home-modal__show" style="padding:38px 40px 40px;">
|
||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
|
||||
@@ -338,7 +347,7 @@ function stripCategoryIcon(value: string) {
|
||||
<div class="home-wizard-header-row">
|
||||
<div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(30px,3vw,36px);line-height:1.08;margin:0 0 14px;color:#3f3556;">Streamer nominieren</h3>
|
||||
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie bis zu drei Stream- oder Kanal-Links ein.</p>
|
||||
<p style="font-size:16px;line-height:1.5;color:#8a8398;margin:0;">Reiche pro Kategorie die erlaubte Anzahl an Stream- oder Kanal-Links ein.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,7 +368,7 @@ function stripCategoryIcon(value: string) {
|
||||
/>
|
||||
<HomeNominationWizardPane
|
||||
v-else
|
||||
:category-name="activeNominationCategory ? stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||
:category-name="activeNominationCategory ? activeNominationCategory.name || stripCategoryIcon(activeNominationCategory.label) : 'Kategorie'"
|
||||
:links="activeNominationLinks"
|
||||
:errors="activeNominationErrors"
|
||||
:visible-link-count="activeNominationVisibleLinkCount"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CinematicStarLoader from '../CinematicStarLoader.vue'
|
||||
import HomeCategoriesSection from './HomeCategoriesSection.vue'
|
||||
@@ -14,7 +15,8 @@ import { useHomeLandingState } from './useHomeLandingState'
|
||||
import { useHomeLandingViewEffects } from './useHomeLandingViewEffects'
|
||||
|
||||
const router = useRouter()
|
||||
const rootStyle = "font-family:'Outfit',sans-serif;min-height:100vh;overflow-x:hidden;line-height:1.5;position:relative;background:#f4eefb;color:#3f3556;--bg:#f4eefb;--bg2:#ebe2f8;--surface:#ffffff;--card:#ffffff;--ink:#3f3556;--muted:#8a8398;--accent:#8b6cdb;--accent2:#b78bff;--gold:#d9942a;--line:rgba(124,86,196,0.16);--glow:rgba(139,108,219,0.3)"
|
||||
const showactModalOpen = ref(false)
|
||||
const rootStyle ="font-family:'Outfit',sans-serif;min-height:100vh;overflow-x:hidden;line-height:1.5;position:relative;background:#f4eefb;color:#3f3556;--bg:#f4eefb;--bg2:#ebe2f8;--surface:#ffffff;--card:#ffffff;--ink:#3f3556;--muted:#8a8398;--accent:#8b6cdb;--accent2:#b78bff;--gold:#d9942a;--line:rgba(124,86,196,0.16);--glow:rgba(139,108,219,0.3)"
|
||||
const {
|
||||
store,
|
||||
authStore,
|
||||
@@ -30,7 +32,7 @@ const {
|
||||
faqItems,
|
||||
privacyContentHtml,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
preparationPhase,
|
||||
@@ -271,7 +273,7 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
|
||||
:on-timeline-final-action="onTimelineFinalAction"
|
||||
/>
|
||||
|
||||
<HomeCategoriesSection :display-categories="displayCategories" />
|
||||
<HomeCategoriesSection :landing-categories="landingCategories" />
|
||||
|
||||
<HomeWinnerShowcaseSection
|
||||
:selected-archive="selectedArchive"
|
||||
@@ -292,7 +294,7 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
|
||||
:on-section-action="onSectionAction"
|
||||
/>
|
||||
|
||||
<HomeExtrasSection />
|
||||
<HomeExtrasSection v-model:modal-open="showactModalOpen" />
|
||||
|
||||
<HomeSupportFooterSection
|
||||
:community-social-links="communitySocialLinks"
|
||||
@@ -300,6 +302,7 @@ const { setRootEl, landingLoaderVisible, stickyCountdownVisible, handleClipSubmi
|
||||
:faq-items="faqItems"
|
||||
:footer-links="footerLinks"
|
||||
:on-open-privacy="onOpenPrivacy"
|
||||
:on-showact-apply="() => { showactModalOpen = true }"
|
||||
:is-uploaded-social-icon="isUploadedSocialIcon"
|
||||
:social-simple-icon-path="socialSimpleIconPath"
|
||||
:social-simple-icon-color="socialSimpleIconColor"
|
||||
|
||||
@@ -29,7 +29,7 @@ const props = defineProps<{
|
||||
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">1</span>
|
||||
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Nominieren</h3>
|
||||
</div>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie bis zu 3 Stream- oder Kanal-Links.</p>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie - pro Kategorie bis zur erlaubten Anzahl an Stream- oder Kanal-Links.</p>
|
||||
</div>
|
||||
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<div style="flex:1;">
|
||||
|
||||
@@ -19,6 +19,8 @@ interface FooterLink {
|
||||
|
||||
interface SiteContent {
|
||||
newsletterUrl: string
|
||||
shareXUrl: string
|
||||
shareDiscordUrl: string
|
||||
}
|
||||
|
||||
interface FaqItem {
|
||||
@@ -32,6 +34,7 @@ const props = defineProps<{
|
||||
faqItems: FaqItem[]
|
||||
footerLinks: FooterLink[]
|
||||
onOpenPrivacy: () => void
|
||||
onShowactApply?: () => void
|
||||
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
|
||||
socialSimpleIconPath: (platform: string | null | undefined) => string
|
||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||
@@ -39,12 +42,18 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const activeFooterLink = ref<FooterLink | null>(null)
|
||||
const linkCopied = ref(false)
|
||||
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
|
||||
const safeNewsletterUrl = computed(() =>
|
||||
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
|
||||
)
|
||||
|
||||
function openFooterLink(event: Event, link: FooterLink) {
|
||||
if (link.key === 'showacts' && props.onShowactApply) {
|
||||
event.preventDefault()
|
||||
props.onShowactApply()
|
||||
return
|
||||
}
|
||||
if (!link.content.trim()) {
|
||||
if (!link.url.trim()) {
|
||||
event.preventDefault()
|
||||
@@ -60,6 +69,16 @@ function closeFooterLink() {
|
||||
activeFooterLink.value = null
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
linkCopied.value = true
|
||||
setTimeout(() => { linkCopied.value = false }, 2000)
|
||||
} catch {
|
||||
// clipboard not available
|
||||
}
|
||||
}
|
||||
|
||||
function isSafePublicUrl(value: string | null | undefined) {
|
||||
if (!value) return false
|
||||
|
||||
@@ -120,9 +139,9 @@ function isSafePublicUrl(value: string | null | undefined) {
|
||||
<h2 style="margin:0 0 14px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
|
||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>Link kopieren</a>
|
||||
<a v-if="props.siteContent.shareXUrl" :href="props.siteContent.shareXUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a v-if="props.siteContent.shareDiscordUrl" :href="props.siteContent.shareDiscordUrl" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a href="#" @click.prevent="copyLink" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>{{ linkCopied ? 'Kopiert ✓' : 'Link kopieren' }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,16 +212,6 @@ function isSafePublicUrl(value: string | null | undefined) {
|
||||
<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>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div :style="nominationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;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:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ nominationPhase ? 'JETZT AKTIV' : 'ABGESCHLOSSEN' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Nominierung</h3>
|
||||
<div style="font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;">{{ formatTimelineRange('nomination') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein — pro Kategorie bis zu drei Nominierungen.</p>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein - pro Kategorie bis zum konfigurierten Limit.</p>
|
||||
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren' : 'Nominierungen ansehen' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import HomeMissingVotesConfirm from './HomeMissingVotesConfirm.vue'
|
||||
import HomeWizardFooter from './HomeWizardFooter.vue'
|
||||
@@ -20,11 +20,38 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const confirmMissingVotes = ref(false)
|
||||
const railRef = ref<HTMLElement | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryRef = ref<HTMLElement | null>(null)
|
||||
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.name))
|
||||
const activeCategoryItem = computed(() => props.catList[props.activeCatIndex] ?? null)
|
||||
const missingVoteCategoryNames = computed(() => props.catList.filter((category) => !category.done).map((category) => category.displayName))
|
||||
const submitLabel = computed(() => props.savedVoteActive ? 'Änderungen speichern' : 'Stimmen absenden')
|
||||
const isLastCategory = computed(() => props.activeCatIndex >= props.catList.length - 1)
|
||||
const groupedCatList = computed(() => {
|
||||
const groups: Array<{
|
||||
name: string
|
||||
items: HomeCategoryListItem[]
|
||||
doneCount: number
|
||||
candidateCount: number
|
||||
}> = []
|
||||
for (const category of props.catList) {
|
||||
const groupName = category.groupName?.trim() || 'Weitere Kategorien'
|
||||
const current = groups.at(-1)
|
||||
if (current?.name === groupName) {
|
||||
current.items.push(category)
|
||||
current.doneCount += category.done ? 1 : 0
|
||||
current.candidateCount += category.candidateCount
|
||||
} else {
|
||||
groups.push({
|
||||
name: groupName,
|
||||
items: [category],
|
||||
doneCount: category.done ? 1 : 0,
|
||||
candidateCount: category.candidateCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
const footerPrimaryLabel = computed(() => {
|
||||
if (!isLastCategory.value) return 'Weiter'
|
||||
if (!props.canSubmitVote) return 'Erst Favorit:in wählen'
|
||||
@@ -81,10 +108,11 @@ function hideMissingVoteConfirm() {
|
||||
|
||||
watch(
|
||||
() => props.activeCatIndex,
|
||||
() => {
|
||||
async () => {
|
||||
confirmMissingVotes.value = false
|
||||
await nextTick()
|
||||
contentRef.value?.scrollTo({ top: 0 })
|
||||
activeCategoryRef.value?.scrollIntoView({ block: 'nearest', inline: 'center' })
|
||||
syncActiveCategoryIntoRail()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -94,6 +122,23 @@ function setCategoryButtonRef(element: unknown, categoryIndex: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncActiveCategoryIntoRail() {
|
||||
const rail = railRef.value
|
||||
const activeButton = activeCategoryRef.value
|
||||
if (!rail || !activeButton) return
|
||||
|
||||
const isHorizontalRail = rail.scrollWidth > rail.clientWidth && rail.clientWidth >= rail.clientHeight
|
||||
if (isHorizontalRail) {
|
||||
const buttonCenter = activeButton.offsetLeft + activeButton.offsetWidth / 2
|
||||
const targetLeft = Math.max(0, buttonCenter - rail.clientWidth / 2)
|
||||
rail.scrollTo({ left: targetLeft })
|
||||
return
|
||||
}
|
||||
|
||||
const targetTop = Math.max(0, activeButton.offsetTop - Math.max(12, (rail.clientHeight - activeButton.offsetHeight) / 2))
|
||||
rail.scrollTo({ top: targetTop })
|
||||
}
|
||||
|
||||
function goToNextCategory() {
|
||||
if (isLastCategory.value) {
|
||||
if (!props.canSubmitVote) return
|
||||
@@ -114,20 +159,28 @@ async function submitConfirmedVote() {
|
||||
|
||||
<template>
|
||||
<div class="home-vote-picker">
|
||||
<aside class="home-vote-picker__rail">
|
||||
<div class="home-vote-picker__rail-label">Kategorien</div>
|
||||
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
|
||||
<aside ref="railRef" class="home-vote-picker__rail">
|
||||
<div class="home-vote-picker__rail-label">Hauptkategorien</div>
|
||||
<section v-for="group in groupedCatList" :key="group.name" class="home-vote-picker__rail-group">
|
||||
<div class="home-vote-picker__rail-group-label">{{ group.name }}</div>
|
||||
<button
|
||||
:ref="(element) => setCategoryButtonRef(element, __idx)"
|
||||
v-for="cat in group.items"
|
||||
:key="cat.idx"
|
||||
:ref="(element) => setCategoryButtonRef(element, cat.idx)"
|
||||
type="button"
|
||||
class="home-vote-picker__category-button"
|
||||
:aria-current="cat.idx === props.activeCatIndex ? 'true' : undefined"
|
||||
@click="cat.onClick"
|
||||
@pointerdown.prevent
|
||||
:style="cat.rowStyle"
|
||||
>
|
||||
<span :style="cat.iconStyle">{{ cat.icon }}</span>
|
||||
<span>{{ cat.name }}</span>
|
||||
<span class="home-vote-picker__category-copy">
|
||||
<span class="home-vote-picker__category-tier">{{ cat.name }}</span>
|
||||
</span>
|
||||
<span v-if="!props.readonlyMode" :style="cat.checkStyle">✓</span>
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section ref="contentRef" class="home-vote-picker__content">
|
||||
@@ -143,7 +196,8 @@ async function submitConfirmedVote() {
|
||||
<template v-else>
|
||||
<header class="home-vote-picker__category-header">
|
||||
<div>
|
||||
<h4>{{ props.activeCatName }}</h4>
|
||||
<p v-if="activeCategoryItem?.groupName" class="home-vote-picker__eyebrow">{{ activeCategoryItem.groupName }}</p>
|
||||
<h4>{{ activeCategoryItem?.name || props.activeCatName }}</h4>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -330,8 +330,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
position:relative;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
height:min(760px,calc(100dvh - 48px));
|
||||
max-height:88vh;
|
||||
flex:1;
|
||||
min-height:0;
|
||||
overflow:hidden;
|
||||
background:
|
||||
@@ -405,6 +404,30 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
color:#a98ddb;
|
||||
}
|
||||
|
||||
.home-wizard-rail__group,
|
||||
.home-vote-picker__rail-group{
|
||||
display:grid;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
.home-wizard-rail__group + .home-wizard-rail__group,
|
||||
.home-vote-picker__rail-group + .home-vote-picker__rail-group{
|
||||
margin-top:8px;
|
||||
padding-top:10px;
|
||||
border-top:1px solid rgba(139,108,219,.12);
|
||||
}
|
||||
|
||||
.home-wizard-rail__group-label,
|
||||
.home-vote-picker__rail-group-label{
|
||||
padding:0 4px;
|
||||
color:#8b6cdb;
|
||||
font-size:10.5px;
|
||||
font-weight:900;
|
||||
letter-spacing:.14em;
|
||||
line-height:1.2;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
|
||||
.home-wizard-rail__button{
|
||||
display:grid;
|
||||
grid-template-columns:auto minmax(0,1fr) auto;
|
||||
@@ -855,6 +878,11 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
scrollbar-gutter:stable;
|
||||
}
|
||||
|
||||
.home-vote-picker__rail-group{
|
||||
display:grid;
|
||||
gap:6px;
|
||||
}
|
||||
|
||||
.home-vote-picker__rail-label{
|
||||
margin:0 4px 6px;
|
||||
font-size:11px;
|
||||
@@ -868,11 +896,32 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
min-height:54px;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-copy{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:2px;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-tier{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-meta{
|
||||
color:#9a91aa;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
line-height:1.2;
|
||||
}
|
||||
|
||||
.home-vote-picker__content{
|
||||
min-width:0;
|
||||
padding:16px 22px 20px;
|
||||
overflow-y:auto;
|
||||
min-height:0;
|
||||
min-block-size:420px;
|
||||
scrollbar-gutter:stable;
|
||||
background:rgba(255,255,255,.18);
|
||||
}
|
||||
@@ -888,6 +937,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
padding:0 0 12px;
|
||||
border-bottom:1px solid rgba(139,108,219,.09);
|
||||
background:transparent;
|
||||
min-height:74px;
|
||||
}
|
||||
|
||||
.home-vote-picker__eyebrow{
|
||||
@@ -913,20 +963,10 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
padding-bottom:22px;
|
||||
}
|
||||
|
||||
.home-vote-picker__hint{
|
||||
max-width:230px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
background:#f4eefd;
|
||||
color:#7d68bd;
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
line-height:1.35;
|
||||
}
|
||||
|
||||
.home-vote-picker__cards{
|
||||
display:grid;
|
||||
gap:12px;
|
||||
align-content:start;
|
||||
}
|
||||
|
||||
.home-nomination-summary-card{
|
||||
@@ -1007,13 +1047,12 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
}
|
||||
|
||||
.home-vote-card:hover{
|
||||
border-color:rgba(139,108,219,.24);
|
||||
box-shadow:0 16px 36px rgba(139,108,219,.14);
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.home-vote-card--selected{
|
||||
border-color:#8b6cdb;
|
||||
border:3px solid #7c4de8;
|
||||
background:
|
||||
radial-gradient(circle at 92% 16%,rgba(139,108,219,.16),transparent 34%),
|
||||
rgba(255,255,255,.92);
|
||||
@@ -1421,6 +1460,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
|
||||
.home-vote-picker__content{
|
||||
padding:14px 18px 18px;
|
||||
min-block-size:360px;
|
||||
}
|
||||
|
||||
.home-vote-card{
|
||||
@@ -1961,6 +2001,7 @@ html,body{margin:0;padding:0;background:#160a26;}
|
||||
.home-vote-picker__content{
|
||||
flex:1 1 auto!important;
|
||||
min-height:0!important;
|
||||
min-block-size:0!important;
|
||||
padding:14px!important;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,20 @@ export interface HomeNominationSubmitContext {
|
||||
|
||||
export interface HomeDisplayCategory {
|
||||
id: string
|
||||
groupName: string
|
||||
name: string
|
||||
icon: string
|
||||
maxNomineesPerUser: number
|
||||
candidates: CandidateSummary[]
|
||||
}
|
||||
|
||||
export interface HomeLandingCategory {
|
||||
id: string
|
||||
groupName: string
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
maxNomineesPerUser: number
|
||||
candidateCount: number
|
||||
subcategoryCount: number
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface HomeCategoryListItem {
|
||||
name: string
|
||||
groupName: string
|
||||
displayName: string
|
||||
icon: string
|
||||
idx: number
|
||||
done: boolean
|
||||
candidateCount: number
|
||||
onClick: () => void
|
||||
rowStyle: string
|
||||
iconStyle: string
|
||||
@@ -33,6 +36,10 @@ export interface HomeNomineeListItem {
|
||||
export interface HomeSelectionOption {
|
||||
id: number
|
||||
label: string
|
||||
name?: string
|
||||
groupName?: string
|
||||
icon?: string
|
||||
maxNomineesPerUser?: number
|
||||
}
|
||||
|
||||
export interface HomeArchiveYearItem {
|
||||
|
||||
@@ -63,7 +63,9 @@ export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<
|
||||
const clipUrl = winner.clipUrl?.trim() || null
|
||||
const clipTitle = winner.clipTitle?.trim() || 'Gewinner-Clip ansehen'
|
||||
const clipPlatform = clipUrl ? winner.clipPlatform?.trim() || winnerPlatformLabel(clipUrl) : 'Kein Clip'
|
||||
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' ? buildClipEmbed(clipUrl) : null
|
||||
const clipEmbed = clipUrl && winner.clipEmbedStatus !== 'link_only' && winner.clipEmbedStatus !== 'blocked'
|
||||
? buildClipEmbed(clipUrl)
|
||||
: null
|
||||
return {
|
||||
category: winner.category,
|
||||
name: winner.winnerName,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
import { buildClipEmbed } from '../../lib/clipEmbeds'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind, HomeLandingCategory } from './homeLandingTypes'
|
||||
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
@@ -31,11 +31,52 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
|
||||
const displayCategories = computed<HomeDisplayCategory[]>(() =>
|
||||
store.categories.categories.map((category, index) => ({
|
||||
id: String(category.id),
|
||||
groupName: category.groupName,
|
||||
name: category.name,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
candidates: category.candidates,
|
||||
})),
|
||||
)
|
||||
const landingCategories = computed<HomeLandingCategory[]>(() => {
|
||||
if (store.overview.featuredCategories.length > 0) {
|
||||
return store.overview.featuredCategories.map((category, index) => {
|
||||
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName === category.groupName)
|
||||
const candidateCount = matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0)
|
||||
return {
|
||||
id: String(category.id),
|
||||
groupName: category.groupName,
|
||||
name: category.groupName,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
description: category.description,
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
candidateCount,
|
||||
subcategoryCount: matchingSubcategories.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const seenGroups = new Set<string>()
|
||||
return displayCategories.value.flatMap((category, index) => {
|
||||
const groupKey = category.groupName.trim().toLowerCase()
|
||||
if (seenGroups.has(groupKey)) {
|
||||
return []
|
||||
}
|
||||
|
||||
seenGroups.add(groupKey)
|
||||
const matchingSubcategories = displayCategories.value.filter((item) => item.groupName.trim().toLowerCase() === groupKey)
|
||||
return [{
|
||||
id: category.id,
|
||||
groupName: category.groupName,
|
||||
name: category.groupName,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
description: '',
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
candidateCount: matchingSubcategories.reduce((sum, item) => sum + item.candidates.length, 0),
|
||||
subcategoryCount: matchingSubcategories.length,
|
||||
}]
|
||||
})
|
||||
})
|
||||
const candidateCount = computed(() =>
|
||||
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
|
||||
)
|
||||
@@ -82,6 +123,7 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
bootstrapArchiveYears,
|
||||
formatRange,
|
||||
@@ -194,9 +236,12 @@ function buildCatList(
|
||||
const done = votes.value[category.id] != null
|
||||
return {
|
||||
name: category.name,
|
||||
groupName: category.groupName,
|
||||
displayName: `${category.groupName} · ${category.name}`,
|
||||
icon: category.icon,
|
||||
idx: index,
|
||||
done,
|
||||
candidateCount: category.candidates.length,
|
||||
onClick: () => setCat(index),
|
||||
rowStyle: baseRow + (active ? 'background:#f1ecfb;border-color:#d8c9f2;color:#5f44ad;' : 'border-color:transparent;color:#6f6685;'),
|
||||
iconStyle: "flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;" + (active ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#efe7fb;color:#9a7fce;'),
|
||||
|
||||
@@ -37,6 +37,7 @@ export function useHomeLandingState() {
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
bootstrapArchiveYears,
|
||||
formatRange,
|
||||
@@ -137,7 +138,7 @@ export function useHomeLandingState() {
|
||||
sectionActionStyle,
|
||||
} = useHomePhasePresentation({
|
||||
previewPhase,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
@@ -263,6 +264,7 @@ export function useHomeLandingState() {
|
||||
privacyContentHtml,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
|
||||
@@ -38,13 +38,31 @@ export function useHomeParticipationPresentation(params: {
|
||||
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
|
||||
const savedVoteActive = computed(() => savedVoteCount.value > 0)
|
||||
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
|
||||
const activeCatName = computed(() => activeCategory.value?.name ?? '')
|
||||
const activeCatName = computed(() => {
|
||||
const category = activeCategory.value
|
||||
return category ? `${category.groupName} · ${category.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 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 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 in jeder Hauptkategorie die passende Unterkategorie und dort deine:n Favorit:in. Eine Stimme pro Unterkategorie.'))
|
||||
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! Deine Links 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)}.` : savedVoteActive.value ? 'Danke! Deine geänderte Auswahl wurde gespeichert.' : '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 catOptions = computed(() => {
|
||||
const seenGroups = new Set<string>()
|
||||
return displayCategories.value.flatMap((category, index) => {
|
||||
const groupKey = category.groupName.trim().toLowerCase()
|
||||
if (seenGroups.has(groupKey)) return []
|
||||
seenGroups.add(groupKey)
|
||||
return [{
|
||||
id: index,
|
||||
label: `${category.icon} ${category.groupName}`,
|
||||
name: category.groupName,
|
||||
groupName: category.groupName,
|
||||
icon: category.icon,
|
||||
maxNomineesPerUser: category.maxNomineesPerUser,
|
||||
}]
|
||||
})
|
||||
})
|
||||
const canSubmitClip = computed(() => clipSubmissionsEnabled.value && previewPhase.value === 'nomination' && clipDsgvo.value)
|
||||
const clipSubmitStyle = computed(() => canSubmitClip.value ? "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);" : "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;")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import { watchAdminErrorToast } from '../../composables/useAdminToast'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
|
||||
@@ -47,6 +48,7 @@ export function useHomeParticipationState(params: {
|
||||
const submitted = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formError = ref('')
|
||||
watchAdminErrorToast(formError)
|
||||
const successKind = ref<null | HomeSuccessKind>(null)
|
||||
const clipCatIdx = ref(0)
|
||||
const clipDsgvo = ref(false)
|
||||
|
||||
@@ -139,8 +139,9 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
return
|
||||
}
|
||||
|
||||
if (entries.some((entry) => entry.streamUrls.length > 3)) {
|
||||
formError.value = 'Pro Kategorie sind maximal drei Links erlaubt.'
|
||||
const overLimitEntry = entries.find((entry) => entry.streamUrls.length > nominationLinkLimitFor(displayCategories.value[entry.categoryIndex]))
|
||||
if (overLimitEntry) {
|
||||
formError.value = `Pro Kategorie sind maximal ${nominationLinkLimitFor(displayCategories.value[overLimitEntry.categoryIndex])} Links erlaubt.`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -171,7 +172,7 @@ export function useHomeParticipationSubmitActions(params: {
|
||||
const category = displayCategories.value[entry.categoryIndex]
|
||||
return store.submitNomination({
|
||||
year: store.overview.year,
|
||||
categoryId: Number(category.id),
|
||||
categoryGroupName: category.groupName,
|
||||
twitchUserId: session.twitchUserId,
|
||||
nominations: entry.streamUrls.map((streamUrl) => ({ streamUrl })),
|
||||
})
|
||||
@@ -260,6 +261,12 @@ function resolveSelectedCreator(category: HomeDisplayCategory | undefined, query
|
||||
?? null
|
||||
}
|
||||
|
||||
function nominationLinkLimitFor(category: HomeDisplayCategory | undefined) {
|
||||
const rawLimit = category?.maxNomineesPerUser
|
||||
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) ? rawLimit : 3
|
||||
return Math.max(1, Math.min(10, Math.round(limit)))
|
||||
}
|
||||
|
||||
function normalizeSearchValue(value: string) {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed, onBeforeUnmount, ref, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import type { HomeDisplayCategory, HomePreviewPhase } from './homeLandingTypes'
|
||||
import type { HomeLandingCategory, HomePreviewPhase } from './homeLandingTypes'
|
||||
|
||||
export function useHomePhasePresentation(params: {
|
||||
previewPhase: Ref<HomePreviewPhase>
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||
landingCategories: ComputedRef<HomeLandingCategory[]>
|
||||
candidateCount: ComputedRef<number>
|
||||
publicStreamUrl: ComputedRef<string>
|
||||
showDate: ComputedRef<string>
|
||||
@@ -15,7 +15,7 @@ export function useHomePhasePresentation(params: {
|
||||
}) {
|
||||
const {
|
||||
previewPhase,
|
||||
displayCategories,
|
||||
landingCategories,
|
||||
candidateCount,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
@@ -40,7 +40,7 @@ export function useHomePhasePresentation(params: {
|
||||
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' : 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 pro Kategorie bis zu drei Stream- oder Kanal-Links 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 phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase laeuft gerade. Reiche pro Kategorie die erlaubte Anzahl an Stream- oder Kanal-Links ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme fuer deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente fuer die Show vor.' : 'Die Award-Show laeuft jetzt live. Zeit fuer Buehne, 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;')
|
||||
@@ -56,11 +56,11 @@ export function useHomePhasePresentation(params: {
|
||||
const streamLockedTitle = computed(() => completedPhase.value ? 'Die Award-Show ist beendet' : 'Verfügbar, sobald die Show startet')
|
||||
const statOneValue = computed(() => completedPhase.value ? 'DONE' : streamLive.value ? 'LIVE' : String(candidateCount.value))
|
||||
const statOneLabel = computed(() => completedPhase.value ? 'Jahr-Status' : showPhase.value ? 'Show-Status' : 'Kandidat:innen')
|
||||
const statTwoValue = computed(() => String(displayCategories.value.length))
|
||||
const statTwoValue = computed(() => String(landingCategories.value.length))
|
||||
const statThreeValue = computed(() => currentYear.value || '—')
|
||||
const statThreeLabel = computed(() => 'Award-Jahr')
|
||||
const categoryIntroText = computed(() => {
|
||||
const count = displayCategories.value.length
|
||||
const count = landingCategories.value.length
|
||||
const countLabel = count === 1 ? 'Eine Kategorie' : `${count || 'Alle'} Kategorien`
|
||||
return `${countLabel}, eine Show, eine Szene. Stimm für deine Lieblings-VTuber ab und sei live dabei, wenn die Gewinner feststehen.`
|
||||
})
|
||||
|
||||
@@ -27,7 +27,9 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
|
||||
)
|
||||
|
||||
const footerLinks = computed(() =>
|
||||
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (isSafePublicUrl(link.url) || link.content)),
|
||||
(siteContent.value.footerLinks ?? []).filter((link) =>
|
||||
link?.label && (link.key === 'showacts' || isSafePublicUrl(link.url) || link.content),
|
||||
),
|
||||
)
|
||||
|
||||
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
|
||||
|
||||
Reference in New Issue
Block a user