Files
harheimertc/pages/index.vue
Torsten Schulz (local) 7c93966878
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
feat: add robots.txt and sitemap.xml routes for SEO optimization
- 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.
2026-05-31 13:36:49 +02:00

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>