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.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -72,32 +74,62 @@ private fun CompactNavigation(
|
||||
onNavigate: (String) -> Unit,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
val routeSection = menuSection(selectedRoute)
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = routeSection ?: sectionOverride.value
|
||||
val subItems = submenu(section, navigationState)
|
||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
||||
val mainScroll = rememberScrollState()
|
||||
val subScroll = rememberScrollState()
|
||||
val cmsSubScroll = rememberScrollState()
|
||||
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate)
|
||||
CompactLink("Verein", Destinations.VereinAbout.route, selectedRoute, onNavigate)
|
||||
CompactLink("Mannschaften", Destinations.Mannschaften.route, selectedRoute, onNavigate)
|
||||
CompactLink("Training", Destinations.Training.route, selectedRoute, onNavigate)
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate)
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate)
|
||||
CompactLink("Newsletter", Destinations.NewsletterSubscribe.route, selectedRoute, onNavigate)
|
||||
if (navigationState.showGallery) {
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(mainScroll),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||
if (navigationState.showGallery) {
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||
}
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
||||
CompactSectionLink("CMS", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||
}
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
CompactLink("Intern", Destinations.MemberArea.route, selectedRoute, onNavigate)
|
||||
|
||||
if (mainScroll.canScrollBackward) {
|
||||
Text(
|
||||
"◀",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.background(Color(0x66000000), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate)
|
||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate)
|
||||
if (mainScroll.canScrollForward) {
|
||||
Text(
|
||||
"▶",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.background(Color(0x66000000), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +142,7 @@ private fun CompactNavigation(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.horizontalScroll(subScroll)
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
@@ -126,12 +158,15 @@ private fun CompactNavigation(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subItems.isNotEmpty()) {
|
||||
ScrollHintRow(subScroll)
|
||||
}
|
||||
|
||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.horizontalScroll(cmsSubScroll)
|
||||
.padding(top = 6.dp, bottom = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -139,6 +174,31 @@ private fun CompactNavigation(
|
||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
||||
}
|
||||
}
|
||||
ScrollHintRow(cmsSubScroll)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSectionLink(
|
||||
label: String,
|
||||
section: MenuSection,
|
||||
activeSection: MenuSection?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onSelect() },
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,11 +345,15 @@ private fun CompactLink(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
beforeNavigate: () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onNavigate(route) },
|
||||
modifier = modifier.clickable {
|
||||
beforeNavigate()
|
||||
onNavigate(route)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
@@ -302,6 +366,30 @@ private fun CompactLink(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollHintRow(scrollState: ScrollState) {
|
||||
if (!scrollState.canScrollBackward && !scrollState.canScrollForward) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (scrollState.canScrollBackward) "◀" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
Text(
|
||||
if (scrollState.canScrollForward) "▶" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
|
||||
@@ -5,11 +5,11 @@ org.gradle.workers.max=2
|
||||
LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
|
||||
# Production backend for Play Store build variant
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=17
|
||||
ANDROID_VERSION_NAME=0.9.12
|
||||
ANDROID_VERSION_CODE=18
|
||||
ANDROID_VERSION_NAME=0.9.13
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
@@ -7,11 +7,22 @@
|
||||
<div class="absolute inset-0 z-0">
|
||||
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-200/30 rounded-full blur-3xl" />
|
||||
<div class="absolute bottom-0 left-0 w-96 h-96 bg-gray-300/30 rounded-full blur-3xl" />
|
||||
<!-- Hintergrundbild -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-10"
|
||||
style="background-image: url('/images/club_about_us.png'); background-size: cover; background-position: center;"
|
||||
/>
|
||||
<picture class="absolute inset-0 opacity-10">
|
||||
<source
|
||||
type="image/webp"
|
||||
srcset="/images/club_about_us_hero_960.webp 960w, /images/club_about_us_hero_1600.webp 1600w"
|
||||
sizes="(max-width: 1024px) 960px, 1600px"
|
||||
>
|
||||
<img
|
||||
src="/images/club_about_us.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="w-full h-full object-cover"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
>
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
|
||||
@@ -77,6 +77,20 @@ export default defineNuxtConfig({
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: 'https://www.harheimertc.de/' },
|
||||
{
|
||||
rel: 'preload',
|
||||
as: 'image',
|
||||
href: '/images/club_about_us_hero_960.webp',
|
||||
type: 'image/webp',
|
||||
media: '(max-width: 1024px)'
|
||||
},
|
||||
{
|
||||
rel: 'preload',
|
||||
as: 'image',
|
||||
href: '/images/club_about_us_hero_1600.webp',
|
||||
type: 'image/webp',
|
||||
media: '(min-width: 1025px)'
|
||||
},
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{
|
||||
|
||||
@@ -226,17 +226,17 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import Hero from '~/components/Hero.vue'
|
||||
import HomeTermine from '~/components/HomeTermine.vue'
|
||||
import Spielplan from '~/components/Spielplan.vue'
|
||||
import PublicNews from '~/components/PublicNews.vue'
|
||||
import HomeActions from '~/components/HomeActions.vue'
|
||||
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
|
||||
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
|
||||
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
|
||||
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.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')
|
||||
|
||||
BIN
public/images/club_about_us_hero_1600.webp
Normal file
BIN
public/images/club_about_us_hero_1600.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/club_about_us_hero_960.webp
Normal file
BIN
public/images/club_about_us_hero_960.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
30
server/routes/robots.txt.js
Normal file
30
server/routes/robots.txt.js
Normal file
@@ -0,0 +1,30 @@
|
||||
function normalizeBaseUrl(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const runtimeConfig = useRuntimeConfig(event)
|
||||
const requestUrl = getRequestURL(event)
|
||||
|
||||
const baseUrl = normalizeBaseUrl(runtimeConfig.public?.baseUrl) || `${requestUrl.protocol}//${requestUrl.host}`
|
||||
|
||||
const lines = [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'Disallow: /cms',
|
||||
'Disallow: /cms/',
|
||||
'Disallow: /mitgliederbereich',
|
||||
'Disallow: /mitgliederbereich/',
|
||||
'Disallow: /api/',
|
||||
'Disallow: /login',
|
||||
'Disallow: /registrieren',
|
||||
'Disallow: /passwort-vergessen',
|
||||
'Disallow: /konto-loeschen',
|
||||
`Sitemap: ${baseUrl}/sitemap.xml`
|
||||
]
|
||||
|
||||
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
|
||||
return `${lines.join('\n')}\n`
|
||||
})
|
||||
87
server/routes/sitemap.xml.js
Normal file
87
server/routes/sitemap.xml.js
Normal file
@@ -0,0 +1,87 @@
|
||||
function normalizeBaseUrl(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(baseUrl, path) {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${baseUrl}${normalizedPath}`
|
||||
}
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const runtimeConfig = useRuntimeConfig(event)
|
||||
const requestUrl = getRequestURL(event)
|
||||
|
||||
const baseUrl = normalizeBaseUrl(runtimeConfig.public?.baseUrl) || `${requestUrl.protocol}//${requestUrl.host}`
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const routes = [
|
||||
'/',
|
||||
'/kontakt',
|
||||
'/termine',
|
||||
'/mitgliedschaft',
|
||||
'/spielplan',
|
||||
'/mannschaften',
|
||||
'/mannschaften/spielplaene',
|
||||
'/mannschaften/herren',
|
||||
'/mannschaften/damen',
|
||||
'/mannschaften/jugend',
|
||||
'/training',
|
||||
'/training/trainer',
|
||||
'/training/anfaenger',
|
||||
'/vereinsmeisterschaften',
|
||||
'/spielsysteme',
|
||||
'/links',
|
||||
'/vorstand',
|
||||
'/impressum',
|
||||
'/datenschutz',
|
||||
'/tt-regeln',
|
||||
'/ueber-uns',
|
||||
'/geschichte',
|
||||
'/satzung',
|
||||
'/galerie',
|
||||
'/verein/ueber-uns',
|
||||
'/verein/geschichte',
|
||||
'/verein/satzung',
|
||||
'/verein/tt-regeln',
|
||||
'/verein/galerie',
|
||||
'/newsletter/subscribe',
|
||||
'/newsletter/unsubscribe'
|
||||
]
|
||||
|
||||
const uniqueRoutes = [...new Set(routes)]
|
||||
|
||||
const entries = uniqueRoutes.map((route) => {
|
||||
const loc = escapeXml(toAbsoluteUrl(baseUrl, route))
|
||||
const priority = route === '/' ? '1.0' : '0.7'
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${loc}</loc>`,
|
||||
` <lastmod>${today}</lastmod>`,
|
||||
' <changefreq>weekly</changefreq>',
|
||||
` <priority>${priority}</priority>`,
|
||||
' </url>'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
entries.join('\n'),
|
||||
'</urlset>',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8')
|
||||
return xml
|
||||
})
|
||||
Reference in New Issue
Block a user