- Implemented a new route for robots.txt to control crawler access. - Added a sitemap.xml route to provide search engines with a list of site URLs. - Included functions for URL normalization and XML escaping to ensure proper formatting.
563 lines
18 KiB
Vue
563 lines
18 KiB
Vue
<template>
|
|
<div class="min-h-full">
|
|
<div
|
|
v-if="canCustomizeHome"
|
|
class="fixed right-4 bottom-14 z-[60]"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="w-9 h-9 rounded-full bg-white border border-gray-200 shadow-sm hover:shadow-md hover:bg-gray-50 flex items-center justify-center text-gray-700"
|
|
:title="editorOpen ? 'Startseiteneditor schließen' : 'Startseiteneditor öffnen'"
|
|
@click="editorOpen ? closeEditor() : openEditor()"
|
|
>
|
|
<X
|
|
v-if="editorOpen"
|
|
:size="15"
|
|
/>
|
|
<SlidersHorizontal
|
|
v-else
|
|
:size="15"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="editorOpen"
|
|
class="fixed right-4 bottom-28 z-[60] w-[min(92vw,30rem)] bg-white border border-gray-200 rounded-xl shadow-xl p-4"
|
|
>
|
|
<div class="flex items-center justify-between gap-3 mb-3">
|
|
<h2 class="text-base font-semibold text-gray-900">
|
|
Startseiteneditor
|
|
</h2>
|
|
</div>
|
|
|
|
<p class="text-xs text-gray-500 mb-3">
|
|
{{ isLoggedIn ? 'Einstellungen werden serverseitig für deinen Nutzer gespeichert.' : 'Einstellungen werden nur im Browser-Cookie gespeichert.' }}
|
|
</p>
|
|
|
|
<div
|
|
v-if="editorSections.length === 0"
|
|
class="text-sm text-gray-600"
|
|
>
|
|
Keine Elemente zur Konfiguration gefunden.
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="space-y-2 max-h-[50vh] overflow-auto pr-1"
|
|
>
|
|
<div
|
|
v-for="(section, index) in editorSections"
|
|
:key="section.key"
|
|
class="p-3 border border-gray-200 rounded-lg bg-gray-50"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-medium text-gray-900 truncate">
|
|
{{ getSectionLabel(section) }}
|
|
</div>
|
|
<div class="text-xs text-gray-500 truncate">
|
|
{{ section.id }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
class="px-2 py-1 text-xs rounded border border-gray-300 bg-white disabled:opacity-50"
|
|
:disabled="index === 0 || isSavingSettings"
|
|
@click="moveEditorSectionUp(index)"
|
|
>
|
|
Hoch
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="px-2 py-1 text-xs rounded border border-gray-300 bg-white disabled:opacity-50"
|
|
:disabled="index === editorSections.length - 1 || isSavingSettings"
|
|
@click="moveEditorSectionDown(index)"
|
|
>
|
|
Runter
|
|
</button>
|
|
</div>
|
|
|
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
<input
|
|
v-model="section.enabled"
|
|
type="checkbox"
|
|
:disabled="isSavingSettings"
|
|
>
|
|
Anzeigen
|
|
</label>
|
|
</div>
|
|
|
|
<div
|
|
v-if="section.id === 'spielplan_team'"
|
|
class="mt-3 grid grid-cols-1 gap-2"
|
|
>
|
|
<div>
|
|
<label class="text-xs text-gray-500">Saison</label>
|
|
<select
|
|
:value="section.config?.season || ''"
|
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
|
:disabled="isSavingSettings || widgetOptionsLoading"
|
|
@change="onWidgetSeasonChanged(section, $event.target.value)"
|
|
>
|
|
<option
|
|
v-for="season in spielplanSeasons"
|
|
:key="season.slug"
|
|
:value="season.slug"
|
|
>
|
|
{{ season.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="text-xs text-gray-500">Mannschaft</label>
|
|
<select
|
|
:value="teamKeyFromConfig(section.config)"
|
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
|
:disabled="isSavingSettings || widgetOptionsLoading"
|
|
@change="onWidgetTeamChanged(section, $event.target.value)"
|
|
>
|
|
<option
|
|
v-for="team in getTeamsForSeason(section.config?.season)"
|
|
:key="team.key"
|
|
:value="team.key"
|
|
>
|
|
{{ team.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 pt-4 border-t border-gray-200">
|
|
<p class="text-sm font-semibold text-gray-900 mb-2">
|
|
Widget hinzufügen
|
|
</p>
|
|
<p class="text-xs text-gray-500 mb-3">
|
|
Spielplan-Widget für eine konkrete Mannschaft und Saison.
|
|
</p>
|
|
|
|
<div class="grid grid-cols-1 gap-2">
|
|
<div>
|
|
<label class="text-xs text-gray-500">Saison</label>
|
|
<select
|
|
v-model="newWidgetSeason"
|
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
|
:disabled="widgetOptionsLoading"
|
|
@change="onNewWidgetSeasonChanged"
|
|
>
|
|
<option
|
|
v-for="season in spielplanSeasons"
|
|
:key="season.slug"
|
|
:value="season.slug"
|
|
>
|
|
{{ season.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="text-xs text-gray-500">Mannschaft</label>
|
|
<select
|
|
v-model="newWidgetTeamKey"
|
|
class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded bg-white"
|
|
:disabled="widgetOptionsLoading"
|
|
>
|
|
<option
|
|
v-for="team in newWidgetTeams"
|
|
:key="team.key"
|
|
:value="team.key"
|
|
>
|
|
{{ team.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="mt-3 px-3 py-2 text-sm rounded-lg border border-primary-300 text-primary-700 hover:bg-primary-50 disabled:opacity-50"
|
|
:disabled="!canAddSpielplanWidget || isSavingSettings"
|
|
@click="addSpielplanWidget"
|
|
>
|
|
Spielplan-Widget hinzufügen
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-4 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
class="px-3 py-2 text-sm rounded-lg bg-primary-600 hover:bg-primary-700 text-white disabled:opacity-50"
|
|
:disabled="isSavingSettings || editorSections.length === 0"
|
|
@click="saveEditor"
|
|
>
|
|
{{ isSavingSettings ? 'Speichert...' : 'Speichern' }}
|
|
</button>
|
|
<p
|
|
v-if="editorMessage"
|
|
class="text-sm"
|
|
:class="editorMessageType === 'error' ? 'text-red-700' : 'text-green-700'"
|
|
>
|
|
{{ editorMessage }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<template
|
|
v-for="section in enabledSections"
|
|
:key="section.key"
|
|
>
|
|
<HomeSpielplanTeamWidget
|
|
v-if="section.id === 'spielplan_team'"
|
|
:season="section.config?.season"
|
|
:team-name="section.config?.teamName"
|
|
:team-age-group="section.config?.teamAgeGroup"
|
|
/>
|
|
<component
|
|
:is="getComponentForSection(section.id)"
|
|
v-else
|
|
/>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, defineAsyncComponent, ref } from 'vue'
|
|
import { SlidersHorizontal, X } from 'lucide-vue-next'
|
|
import Hero from '~/components/Hero.vue'
|
|
const HomeTermine = defineAsyncComponent(() => import('~/components/HomeTermine.vue'))
|
|
const Spielplan = defineAsyncComponent(() => import('~/components/Spielplan.vue'))
|
|
const PublicNews = defineAsyncComponent(() => import('~/components/PublicNews.vue'))
|
|
const HomeActions = defineAsyncComponent(() => import('~/components/HomeActions.vue'))
|
|
const HomeTrainingTeaser = defineAsyncComponent(() => import('~/components/HomeTrainingTeaser.vue'))
|
|
const HomeLinksTeaser = defineAsyncComponent(() => import('~/components/HomeLinksTeaser.vue'))
|
|
const HomeVereinsmeisterschaftenTeaser = defineAsyncComponent(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
|
|
const HomeSpielplanTeamWidget = defineAsyncComponent(() => import('~/components/HomeSpielplanTeamWidget.vue'))
|
|
|
|
const { data: config } = await useFetch('/api/config')
|
|
const { data: authStatus } = await useFetch('/api/auth/status')
|
|
const { data: homepageSettings, refresh: refreshHomepageSettings } = await useFetch('/api/homepage/settings')
|
|
|
|
const editorOpen = ref(false)
|
|
const editorSections = ref([])
|
|
const isSavingSettings = ref(false)
|
|
const editorMessage = ref('')
|
|
const editorMessageType = ref('success')
|
|
const widgetOptionsLoading = ref(false)
|
|
const spielplanSeasons = ref([])
|
|
const teamOptionsBySeason = ref({})
|
|
const newWidgetSeason = ref('')
|
|
const newWidgetTeamKey = ref('')
|
|
|
|
const baseSectionDefinitions = [
|
|
{ id: 'banner', enabled: true },
|
|
{ id: 'termine', enabled: true },
|
|
{ id: 'spiele', enabled: true },
|
|
{ id: 'aktuelles', enabled: true },
|
|
{ id: 'kontakt', enabled: true },
|
|
{ id: 'training', enabled: false },
|
|
{ id: 'links', enabled: false },
|
|
{ id: 'vereinsmeisterschaften', enabled: false }
|
|
]
|
|
const baseSectionIds = new Set(baseSectionDefinitions.map(section => section.id))
|
|
|
|
function createEntryKey(id) {
|
|
return `${id}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
}
|
|
|
|
function normalizeConfig(config) {
|
|
if (!config || typeof config !== 'object') return undefined
|
|
const normalized = {
|
|
season: config.season ? String(config.season) : '',
|
|
teamName: config.teamName ? String(config.teamName) : '',
|
|
teamAgeGroup: config.teamAgeGroup ? String(config.teamAgeGroup) : ''
|
|
}
|
|
if (!normalized.season && !normalized.teamName && !normalized.teamAgeGroup) {
|
|
return undefined
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function normalizeEntry(entry, index, fallbackId = '') {
|
|
const id = String(entry?.id || fallbackId || '').trim()
|
|
if (!id) return null
|
|
return {
|
|
key: entry?.key ? String(entry.key) : `${id}-${index}`,
|
|
id,
|
|
enabled: entry?.enabled !== false,
|
|
config: normalizeConfig(entry?.config)
|
|
}
|
|
}
|
|
|
|
function normalizeSectionList(rawSections) {
|
|
const incoming = Array.isArray(rawSections) ? rawSections : []
|
|
const sanitized = incoming
|
|
.map((section, index) => normalizeEntry(section, index))
|
|
.filter(Boolean)
|
|
|
|
if (sanitized.length === 0) {
|
|
return baseSectionDefinitions.map((section, index) => normalizeEntry(
|
|
{ ...section, key: `base-${section.id}` },
|
|
index,
|
|
section.id
|
|
))
|
|
}
|
|
|
|
const knownIds = new Set(sanitized.map(section => section.id))
|
|
const merged = [...sanitized]
|
|
for (const defaultSection of baseSectionDefinitions) {
|
|
if (!knownIds.has(defaultSection.id)) {
|
|
merged.push(normalizeEntry(
|
|
{ ...defaultSection, key: `base-${defaultSection.id}` },
|
|
merged.length,
|
|
defaultSection.id
|
|
))
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
const sections = computed(() => normalizeSectionList(config.value?.homepage?.sections))
|
|
const personalizedSections = computed(() => {
|
|
const raw = homepageSettings.value?.sections
|
|
const list = Array.isArray(raw) ? raw : []
|
|
return list.map((section, index) => normalizeEntry(section, index)).filter(Boolean)
|
|
})
|
|
|
|
const isLoggedIn = computed(() => authStatus.value?.isLoggedIn === true)
|
|
const canCustomizeHome = computed(() => sections.value.length > 0)
|
|
|
|
function applyPersonalization(baseSections, settingsSections) {
|
|
if (!settingsSections.length) return baseSections
|
|
|
|
const presentBaseIds = new Set(
|
|
settingsSections.filter(section => baseSectionIds.has(section.id)).map(section => section.id)
|
|
)
|
|
const missingBaseSections = baseSections.filter(section => !presentBaseIds.has(section.id))
|
|
return [...settingsSections, ...missingBaseSections]
|
|
}
|
|
|
|
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
|
|
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
|
|
|
|
const componentMap = {
|
|
banner: Hero,
|
|
termine: HomeTermine,
|
|
spiele: Spielplan,
|
|
aktuelles: PublicNews,
|
|
kontakt: HomeActions,
|
|
training: HomeTrainingTeaser,
|
|
links: HomeLinksTeaser,
|
|
vereinsmeisterschaften: HomeVereinsmeisterschaftenTeaser
|
|
}
|
|
|
|
function getComponentForSection(sectionId) {
|
|
return componentMap[sectionId] || null
|
|
}
|
|
|
|
function getSectionLabel(section) {
|
|
if (section.id === 'spielplan_team') {
|
|
if (!section.config?.teamName) return 'Spielplan-Widget'
|
|
return `Spielplan: ${section.config.teamName}`
|
|
}
|
|
|
|
const labels = {
|
|
banner: 'Banner (Willkommen)',
|
|
termine: 'Kommende Termine',
|
|
spiele: 'Nächste Spiele',
|
|
aktuelles: 'Aktuelles',
|
|
kontakt: 'Kontakt-Boxen',
|
|
training: 'Training-Teaser',
|
|
links: 'Links-Teaser',
|
|
vereinsmeisterschaften: 'Vereinsmeisterschaften-Teaser'
|
|
}
|
|
return labels[section.id] || section.id
|
|
}
|
|
|
|
function getTeamsForSeason(seasonSlug) {
|
|
if (!seasonSlug) return []
|
|
return teamOptionsBySeason.value[seasonSlug] || []
|
|
}
|
|
|
|
function teamKeyFromConfig(config) {
|
|
if (!config?.teamName) return ''
|
|
return `${config.teamName}||${config.teamAgeGroup || ''}`
|
|
}
|
|
|
|
function applyTeamToSectionConfig(section, teamKey) {
|
|
const season = section.config?.season || ''
|
|
const teams = getTeamsForSeason(season)
|
|
const team = teams.find(item => item.key === teamKey)
|
|
if (!team) return
|
|
section.config = {
|
|
...section.config,
|
|
season,
|
|
teamName: team.teamName,
|
|
teamAgeGroup: team.teamAgeGroup || ''
|
|
}
|
|
}
|
|
|
|
async function ensureTeamOptions(seasonSlug) {
|
|
if (!seasonSlug || teamOptionsBySeason.value[seasonSlug]) return
|
|
const result = await $fetch('/api/homepage/spielplan-options', {
|
|
query: { season: seasonSlug }
|
|
})
|
|
teamOptionsBySeason.value = {
|
|
...teamOptionsBySeason.value,
|
|
[seasonSlug]: result?.teams || []
|
|
}
|
|
if (!spielplanSeasons.value.length && Array.isArray(result?.seasons)) {
|
|
spielplanSeasons.value = result.seasons
|
|
}
|
|
}
|
|
|
|
async function loadWidgetOptions() {
|
|
if (widgetOptionsLoading.value) return
|
|
widgetOptionsLoading.value = true
|
|
try {
|
|
const result = await $fetch('/api/homepage/spielplan-options')
|
|
spielplanSeasons.value = Array.isArray(result?.seasons) ? result.seasons : []
|
|
|
|
const selectedSeason = result?.selectedSeason || spielplanSeasons.value[0]?.slug || ''
|
|
if (selectedSeason) {
|
|
teamOptionsBySeason.value = {
|
|
...teamOptionsBySeason.value,
|
|
[selectedSeason]: result?.teams || []
|
|
}
|
|
}
|
|
|
|
if (!newWidgetSeason.value) {
|
|
newWidgetSeason.value = selectedSeason
|
|
}
|
|
if (newWidgetSeason.value) {
|
|
await ensureTeamOptions(newWidgetSeason.value)
|
|
const teams = getTeamsForSeason(newWidgetSeason.value)
|
|
if (teams.length && !newWidgetTeamKey.value) {
|
|
newWidgetTeamKey.value = teams[0].key
|
|
}
|
|
}
|
|
} finally {
|
|
widgetOptionsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function openEditor() {
|
|
editorMessage.value = ''
|
|
editorSections.value = resolvedSections.value.map(section => ({
|
|
key: section.key,
|
|
id: section.id,
|
|
enabled: section.enabled !== false,
|
|
config: normalizeConfig(section.config)
|
|
}))
|
|
|
|
await loadWidgetOptions()
|
|
|
|
for (const section of editorSections.value.filter(item => item.id === 'spielplan_team')) {
|
|
const fallbackSeason = section.config?.season || newWidgetSeason.value || spielplanSeasons.value[0]?.slug || ''
|
|
if (!section.config) section.config = {}
|
|
section.config.season = fallbackSeason
|
|
await ensureTeamOptions(fallbackSeason)
|
|
const currentTeamKey = teamKeyFromConfig(section.config)
|
|
const availableTeams = getTeamsForSeason(fallbackSeason)
|
|
if (availableTeams.length && !availableTeams.find(item => item.key === currentTeamKey)) {
|
|
applyTeamToSectionConfig(section, availableTeams[0].key)
|
|
}
|
|
}
|
|
|
|
editorOpen.value = true
|
|
}
|
|
|
|
function closeEditor() {
|
|
editorOpen.value = false
|
|
editorMessage.value = ''
|
|
}
|
|
|
|
function moveEditorSectionUp(index) {
|
|
if (index <= 0) return
|
|
const item = editorSections.value[index]
|
|
editorSections.value.splice(index, 1)
|
|
editorSections.value.splice(index - 1, 0, item)
|
|
}
|
|
|
|
function moveEditorSectionDown(index) {
|
|
if (index >= editorSections.value.length - 1) return
|
|
const item = editorSections.value[index]
|
|
editorSections.value.splice(index, 1)
|
|
editorSections.value.splice(index + 1, 0, item)
|
|
}
|
|
|
|
async function onWidgetSeasonChanged(section, seasonSlug) {
|
|
if (!section.config) section.config = {}
|
|
section.config.season = seasonSlug
|
|
await ensureTeamOptions(seasonSlug)
|
|
const teams = getTeamsForSeason(seasonSlug)
|
|
const currentTeamKey = teamKeyFromConfig(section.config)
|
|
if (teams.length && !teams.find(item => item.key === currentTeamKey)) {
|
|
applyTeamToSectionConfig(section, teams[0].key)
|
|
}
|
|
}
|
|
|
|
function onWidgetTeamChanged(section, teamKey) {
|
|
applyTeamToSectionConfig(section, teamKey)
|
|
}
|
|
|
|
const newWidgetTeams = computed(() => getTeamsForSeason(newWidgetSeason.value))
|
|
const canAddSpielplanWidget = computed(() => !!newWidgetSeason.value && !!newWidgetTeamKey.value)
|
|
|
|
async function onNewWidgetSeasonChanged() {
|
|
await ensureTeamOptions(newWidgetSeason.value)
|
|
const teams = getTeamsForSeason(newWidgetSeason.value)
|
|
newWidgetTeamKey.value = teams[0]?.key || ''
|
|
}
|
|
|
|
function addSpielplanWidget() {
|
|
const teams = getTeamsForSeason(newWidgetSeason.value)
|
|
const selectedTeam = teams.find(team => team.key === newWidgetTeamKey.value)
|
|
if (!selectedTeam) return
|
|
|
|
editorSections.value.push({
|
|
key: createEntryKey('spielplan_team'),
|
|
id: 'spielplan_team',
|
|
enabled: true,
|
|
config: {
|
|
season: newWidgetSeason.value,
|
|
teamName: selectedTeam.teamName,
|
|
teamAgeGroup: selectedTeam.teamAgeGroup || ''
|
|
}
|
|
})
|
|
}
|
|
|
|
async function saveEditor() {
|
|
isSavingSettings.value = true
|
|
editorMessage.value = ''
|
|
|
|
try {
|
|
await $fetch('/api/homepage/settings', {
|
|
method: 'PUT',
|
|
body: {
|
|
sections: editorSections.value.map((section, index) => ({
|
|
key: section.key || `${section.id}-${index}`,
|
|
id: section.id,
|
|
enabled: section.enabled !== false,
|
|
config: normalizeConfig(section.config)
|
|
}))
|
|
}
|
|
})
|
|
|
|
await refreshHomepageSettings()
|
|
editorMessageType.value = 'success'
|
|
editorMessage.value = 'Startseiten-Einstellungen gespeichert.'
|
|
} catch (error) {
|
|
editorMessageType.value = 'error'
|
|
editorMessage.value = error?.data?.message || 'Speichern fehlgeschlagen.'
|
|
} finally {
|
|
isSavingSettings.value = false
|
|
}
|
|
}
|
|
</script>
|