Initial commit: Harheimer TC Website

- Vue 3 + Nuxt 3 Framework
- Tailwind CSS Styling
- Responsive Design mit schwarz-roten Vereinsfarben
- Dynamische Galerie mit Lightbox
- Event-Management über CSV-Dateien
- Mannschaftsübersicht mit dynamischen Seiten
- SMTP-Kontaktformular
- Google Maps Integration
- Mobile-optimierte Navigation mit Submenus
- Trainer-Übersicht
- Vereinsmeisterschaften, Spielsysteme, TT-Regeln
- Impressum mit Datenschutzerklärung
This commit is contained in:
Torsten Schulz (local)
2025-10-21 00:41:12 +02:00
commit 737c3064bd
61 changed files with 25816 additions and 0 deletions

106
components/About.vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<section id="about" class="py-16 sm:py-20 bg-gradient-to-b from-white to-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Über uns
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Seit über 70 Jahren wird in unserem Harheimer Verein Tischtennis gespielt
</p>
</div>
<div class="grid lg:grid-cols-2 gap-12 items-center mb-20">
<div class="relative h-[400px] sm:h-[500px] rounded-2xl overflow-hidden shadow-2xl">
<div
class="w-full h-full bg-cover bg-center hover:scale-110 transition-transform duration-700"
style="background-image: url('https://images.unsplash.com/photo-1611004275469-8583ed5d7b8d?q=80&w=2070')"
/>
</div>
<div class="space-y-6">
<h3 class="text-3xl font-display font-bold text-gray-900">
Ein familiärer Verein mit Tradition
</h3>
<p class="text-lg text-gray-600 leading-relaxed">
Wir sind ein kleiner, selbständiger, familiärer Verein mit ca. 40 Mitgliedern.
Wir nehmen zurzeit mit fünf Herrenmannschaften an der Punktspielrunde teil.
</p>
<p class="text-lg text-gray-600 leading-relaxed">
Ab der Saison 2025/26 werden wir auch wieder mit einer Jugendmannschaft aktiv.
</p>
<p class="text-lg text-gray-600 leading-relaxed">
Wir trainieren zweimal wöchentlich in der Turnhalle der Grundschule Harheim mit
anschließendem gemütlichem Beisammensein in einer der lokalen Gaststätten.
Jährlich finden außerdem unsere Vereinsmeisterschaften statt.
</p>
<div class="bg-primary-50 border-l-4 border-primary-600 p-6 rounded-lg">
<h4 class="text-xl font-semibold text-primary-800 mb-3">Wir suchen Verstärkung!</h4>
<p class="text-primary-700 mb-4">
Wir suchen ständig Verstärkungen für unsere Damen- und Herrenmannschaften!
</p>
<p class="text-primary-700 font-medium">
Alle Tischtennis-Begeisterten sind herzlich zu einem Probetraining eingeladen!
</p>
</div>
<div class="pt-4">
<NuxtLink
to="/kontakt"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
Kontakt aufnehmen
</NuxtLink>
</div>
</div>
</div>
<!-- Values -->
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
<div
v-for="value in values"
:key="value.title"
class="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition-shadow border border-gray-100"
>
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-4">
<component :is="value.icon" :size="24" class="text-primary-600" />
</div>
<h4 class="text-xl font-display font-bold text-gray-900 mb-2">
{{ value.title }}
</h4>
<p class="text-gray-600">
{{ value.description }}
</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Heart, Award, Target, Users2 } from 'lucide-vue-next'
const values = [
{
icon: Heart,
title: '70+ Jahre Tradition',
description: 'Seit 1954 spielen wir Tischtennis in Harheim',
},
{
icon: Users2,
title: 'Familiärer Verein',
description: 'Ca. 40 Mitglieder in einer herzlichen Gemeinschaft',
},
{
icon: Award,
title: '5 Herrenmannschaften',
description: 'Aktive Teilnahme an der Punktspielrunde',
},
{
icon: Target,
title: 'Jugendförderung',
description: 'Ab 2025/26 wieder eine Jugendmannschaft',
},
]
</script>

124
components/Calendar.vue Normal file
View File

@@ -0,0 +1,124 @@
<template>
<section id="calendar" class="py-16 sm:py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Termine & Events
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Entdecken Sie unsere kommenden Veranstaltungen und Turniere
</p>
</div>
<div class="relative">
<!-- Timeline line -->
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 h-full w-0.5 bg-gradient-to-b from-primary-200 via-primary-400 to-primary-200" />
<div class="space-y-12">
<div
v-for="(event, index) in events"
:key="event.title"
:class="[
'relative flex items-center',
index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'
]"
>
<div :class="['w-full lg:w-5/12', index % 2 === 0 ? 'lg:pr-12' : 'lg:pl-12']">
<div class="bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-shadow p-6 border border-gray-100">
<div class="flex items-start space-x-4">
<div :class="['flex-shrink-0 w-14 h-14 bg-gradient-to-br rounded-xl flex items-center justify-center', event.color]">
<component :is="event.icon" :size="28" class="text-white" />
</div>
<div class="flex-1">
<div class="text-sm font-semibold text-primary-600 mb-1">
{{ event.date }}
</div>
<h3 class="text-xl font-display font-bold text-gray-900 mb-2">
{{ event.title }}
</h3>
<p class="text-gray-600">
{{ event.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Timeline dot -->
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2">
<div :class="['w-4 h-4 bg-gradient-to-br rounded-full border-4 border-white shadow-lg', event.color]" />
</div>
<div class="hidden lg:block w-5/12" />
</div>
</div>
</div>
<div class="mt-16 text-center">
<div class="bg-gray-50 rounded-2xl p-8 max-w-2xl mx-auto">
<CalendarIcon :size="48" class="text-primary-600 mx-auto mb-4" />
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
Regelmäßige Angebote
</h3>
<div class="space-y-2 text-gray-700">
<p><strong>Dienstag & Donnerstag:</strong> Jugendtraining (17:00 - 19:00 Uhr)</p>
<p><strong>Mittwoch:</strong> Hobbygruppe (19:00 - 21:00 Uhr)</p>
<p><strong>Freitag:</strong> Wettkampftraining (18:00 - 20:00 Uhr)</p>
<p><strong>Wochenende:</strong> Punktspiele & Ligabetrieb</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Calendar as CalendarIcon, Trophy, Users, PartyPopper } from 'lucide-vue-next'
const events = [
{
date: '15. Mai 2025',
title: 'Saisoneröffnung',
description: 'Offizieller Start in die Tennissaison mit großem Eröffnungsfest',
icon: PartyPopper,
color: 'from-pink-500 to-rose-500',
},
{
date: '20-22. Juni 2025',
title: 'Clubmeisterschaft',
description: 'Spannende Matches um den Clubmeister-Titel',
icon: Trophy,
color: 'from-yellow-500 to-orange-500',
},
{
date: '15. Juli 2025',
title: 'Jugend-Turnier',
description: 'Nachwuchsturnier für alle Altersklassen',
icon: Users,
color: 'from-blue-500 to-cyan-500',
},
{
date: '10. August 2025',
title: 'Sommerfest',
description: 'Geselliges Beisammensein mit Grillen und Live-Musik',
icon: PartyPopper,
color: 'from-green-500 to-emerald-500',
},
{
date: '1-3. September 2025',
title: 'Vereinsmeisterschaft',
description: 'Das Highlight der Saison - Vereinsmeisterschaft in allen Kategorien',
icon: Trophy,
color: 'from-purple-500 to-indigo-500',
},
{
date: '30. September 2025',
title: 'Saisonabschluss',
description: 'Gemütlicher Ausklang der Saison mit Siegerehrung',
icon: CalendarIcon,
color: 'from-red-500 to-pink-500',
},
]
</script>

238
components/Contact.vue Normal file
View File

@@ -0,0 +1,238 @@
<template>
<section id="contact" class="py-16 sm:py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Kontakt
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Wir freuen uns auf Ihre Nachricht - Kontaktieren Sie uns!
</p>
</div>
<div class="grid lg:grid-cols-2 gap-12 items-start">
<!-- Contact Information -->
<div class="space-y-6">
<div
v-for="info in contactInfo"
:key="info.title"
class="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl hover:shadow-lg transition-shadow"
>
<div :class="['flex-shrink-0 w-12 h-12 bg-gradient-to-br rounded-lg flex items-center justify-center', info.color]">
<component :is="info.icon" :size="24" class="text-white" />
</div>
<div>
<h3 class="font-display font-bold text-gray-900 mb-2">
{{ info.title }}
</h3>
<p v-for="(line, i) in info.content" :key="i" class="text-gray-600">
{{ line }}
</p>
</div>
</div>
<!-- Map & Link -->
<div class="space-y-4">
<div class="rounded-2xl overflow-hidden shadow-xl h-64">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2554.5!2d8.660947!3d50.187044!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x47bd0e5e5e5e5e5e%3A0x5e5e5e5e5e5e5e5e!2sIn%20den%20Schafg%C3%A4rten%2025%2C%2060437%20Frankfurt%20am%20Main!5e0!3m2!1sde!2sde!4v1234567890"
width="100%"
height="100%"
style="border: 0"
loading="lazy"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
title="Sporthalle der Grundschule Harheim"
/>
</div>
<a
href="https://www.google.com/maps/search/?api=1&query=In+den+Schafgärten+25+60437+Frankfurt"
target="_blank"
class="block text-center px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
In Google Maps öffnen
</a>
</div>
</div>
<!-- Contact Form -->
<div class="bg-gradient-to-br from-primary-50 to-primary-100/50 rounded-2xl p-8 shadow-xl">
<h3 class="text-2xl font-display font-bold text-gray-900 mb-6">
Senden Sie uns eine Nachricht
</h3>
<form class="space-y-4" @submit.prevent="sendEmail">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
id="name"
v-model="formData.name"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="Ihr Name"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
E-Mail *
</label>
<input
type="email"
id="email"
v-model="formData.email"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="ihre@email.de"
/>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">
Telefon
</label>
<input
type="tel"
id="phone"
v-model="formData.phone"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="+49 123 456789"
/>
</div>
<div>
<label for="subject" class="block text-sm font-medium text-gray-700 mb-1">
Betreff *
</label>
<input
type="text"
id="subject"
v-model="formData.subject"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
placeholder="Worum geht es?"
/>
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">
Nachricht *
</label>
<textarea
id="message"
v-model="formData.message"
required
rows="5"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all resize-none"
placeholder="Ihre Nachricht..."
/>
</div>
<!-- Status Message -->
<div v-if="submitStatus" class="p-4 rounded-lg" :class="submitStatus === 'success' ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
<div class="flex items-center">
<CheckCircle v-if="submitStatus === 'success'" :size="20" class="text-green-600 mr-2" />
<AlertCircle v-else :size="20" class="text-red-600 mr-2" />
<p :class="submitStatus === 'success' ? 'text-green-800' : 'text-red-800'" class="text-sm font-medium">
{{ submitMessage }}
</p>
</div>
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full px-6 py-4 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center"
>
<Send v-if="!isSubmitting" :size="20" class="mr-2" />
<div v-else class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
{{ isSubmitting ? 'Wird gesendet...' : 'E-Mail senden' }}
</button>
<p class="text-sm text-gray-600 text-center">
* Pflichtfelder
</p>
</form>
<p class="mt-4 text-sm text-gray-600 text-center">
Ihre Nachricht wird direkt an j.dichmann@gmx.de gesendet
</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import { MapPin, Phone, Mail, Clock, Send, CheckCircle, AlertCircle } from 'lucide-vue-next'
const formData = ref({
name: '',
email: '',
phone: '',
subject: '',
message: ''
})
const isSubmitting = ref(false)
const submitStatus = ref(null) // 'success', 'error', null
const submitMessage = ref('')
const sendEmail = async () => {
isSubmitting.value = true
submitStatus.value = null
submitMessage.value = ''
try {
const response = await $fetch('/api/contact', {
method: 'POST',
body: formData.value
})
if (response.success) {
submitStatus.value = 'success'
submitMessage.value = 'E-Mail wurde erfolgreich gesendet! Wir melden uns bald bei Ihnen.'
// Formular zurücksetzen
formData.value = {
name: '',
email: '',
phone: '',
subject: '',
message: ''
}
}
} catch (error) {
console.error('Fehler beim Senden:', error)
submitStatus.value = 'error'
submitMessage.value = error.data?.message || 'Fehler beim Senden der E-Mail. Bitte versuchen Sie es später erneut.'
} finally {
isSubmitting.value = false
}
}
const contactInfo = [
{
icon: MapPin,
title: 'Trainingsort',
content: ['Sporthalle der Grundschule Harheim', 'In den Schafgärten 25', '60437 Frankfurt/Main'],
color: 'from-red-500 to-pink-500',
},
{
icon: Phone,
title: 'Telefon',
content: ['06101-4992227'],
color: 'from-green-500 to-emerald-500',
},
{
icon: Mail,
title: 'E-Mail',
content: ['j.dichmann@gmx.de'],
color: 'from-blue-500 to-cyan-500',
},
{
icon: Clock,
title: 'Trainingszeiten',
content: ['Dienstag: 19:30 - 22:30 Uhr', 'Donnerstag: 19:30 - 22:30 Uhr'],
color: 'from-purple-500 to-indigo-500',
},
]
</script>

102
components/Facilities.vue Normal file
View File

@@ -0,0 +1,102 @@
<template>
<section id="facilities" class="py-16 sm:py-20 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Unsere Anlagen
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Moderne Ausstattung und erstklassige Einrichtungen für ein perfektes Tischtenniserlebnis
</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
<div
v-for="facility in facilities"
:key="facility.title"
class="group relative bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-gray-100"
>
<div :class="['absolute top-0 left-0 right-0 h-1 bg-gradient-to-r opacity-0 group-hover:opacity-100 transition-opacity', facility.color]" />
<div class="p-8">
<div :class="['w-16 h-16 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform', facility.color]">
<component :is="facility.icon" :size="32" class="text-white" />
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-3">
{{ facility.title }}
</h3>
<p class="text-gray-600 leading-relaxed">
{{ facility.description }}
</p>
</div>
</div>
</div>
<!-- Image Gallery -->
<div class="grid md:grid-cols-2 gap-6">
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
<div
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
style="background-image: url('https://images.unsplash.com/photo-1534438097545-77fef53fe2e8?q=80&w=2070')"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<p class="text-white font-semibold text-xl p-6">Hochwertige Wettkampftische</p>
</div>
</div>
<div class="relative h-[300px] rounded-2xl overflow-hidden shadow-xl group">
<div
class="w-full h-full bg-cover bg-center group-hover:scale-110 transition-transform duration-700"
style="background-image: url('https://images.unsplash.com/photo-1611004275469-8583ed5d7b8d?q=80&w=2070')"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<p class="text-white font-semibold text-xl p-6">Moderne Tischtennishalle</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Sun, CloudRain, Dumbbell, Utensils, Wifi, Droplets } from 'lucide-vue-next'
const facilities = [
{
icon: Sun,
title: '8 Tischtennisplatten',
description: 'Hochwertige Wettkampftische für optimales Spielvergnügen',
color: 'from-yellow-400 to-orange-500',
},
{
icon: CloudRain,
title: 'Klimatisierte Halle',
description: 'Optimale Bedingungen bei jedem Wetter in unserer modernen Halle',
color: 'from-blue-400 to-blue-600',
},
{
icon: Dumbbell,
title: 'Trainingsbereich',
description: 'Ballmaschinen und Trainingsgeräte für gezieltes Training',
color: 'from-red-400 to-red-600',
},
{
icon: Utensils,
title: 'Clubhaus',
description: 'Gemütliches Clubhaus mit Aufenthaltsraum und Küche',
color: 'from-green-400 to-green-600',
},
{
icon: Wifi,
title: 'Kostenloses WLAN',
description: 'Schnelles Internet auf der gesamten Anlage',
color: 'from-purple-400 to-purple-600',
},
{
icon: Droplets,
title: 'Umkleiden & Duschen',
description: 'Moderne, saubere Umkleideräume mit Duschen',
color: 'from-cyan-400 to-cyan-600',
},
]
</script>

23
components/Footer.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<footer class="fixed bottom-0 left-0 right-0 z-40 bg-gray-900 border-t border-gray-800 shadow-2xl">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<div class="flex flex-col sm:flex-row justify-between items-center space-y-2 sm:space-y-0">
<p class="text-sm text-gray-400">
© {{ currentYear }} Harheimer TC
</p>
<div class="flex items-center space-x-6 text-sm">
<NuxtLink to="/impressum" class="text-gray-400 hover:text-primary-400 transition-colors">
Impressum
</NuxtLink>
<NuxtLink to="/kontakt" class="text-gray-400 hover:text-primary-400 transition-colors">
Kontakt
</NuxtLink>
</div>
</div>
</div>
</footer>
</template>
<script setup>
const currentYear = new Date().getFullYear()
</script>

106
components/Gallery.vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<section v-if="images.length > 0" id="gallery" class="py-16 sm:py-20 bg-gradient-to-b from-white to-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Galerie
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Eindrücke von unserem Verein
</p>
</div>
<div class="grid sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2">
<div
v-for="image in images"
:key="image.filename"
class="group relative w-20 h-20 rounded-md overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 cursor-pointer"
@click="openLightbox(image)"
>
<img
:src="`/galerie/${image.filename}`"
:alt="image.title"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
<p class="text-white font-semibold text-xs p-1 truncate">{{ image.title }}</p>
</div>
</div>
</div>
<!-- Lightbox Modal -->
<div
v-if="lightboxImage"
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
@click="closeLightbox"
>
<div class="relative w-full h-full flex items-center justify-center">
<button
@click.stop="closeLightbox"
class="absolute top-4 right-4 z-10 w-10 h-10 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center text-white transition-colors"
>
<X :size="24" />
</button>
<img
:src="`/galerie/${lightboxImage.filename}`"
:alt="lightboxImage.title"
class="max-w-[80vw] max-h-[80vh] object-contain rounded-lg"
@click.stop
/>
<div class="absolute bottom-4 left-4 right-4 text-center">
<p class="text-white font-semibold text-lg bg-black/50 rounded-lg px-4 py-2">
{{ lightboxImage.title }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { X } from 'lucide-vue-next'
const images = ref([])
const lightboxImage = ref(null)
const loadImages = async () => {
try {
const response = await $fetch('/api/galerie')
images.value = response || []
} catch (error) {
console.error('Fehler beim Laden der Galerie-Bilder:', error)
images.value = []
}
}
const openLightbox = (image) => {
lightboxImage.value = image
document.body.style.overflow = 'hidden' // Verhindert Scrollen im Hintergrund
}
const closeLightbox = () => {
lightboxImage.value = null
document.body.style.overflow = 'auto' // Erlaubt wieder Scrollen
}
// ESC-Taste zum Schließen
const handleKeydown = (event) => {
if (event.key === 'Escape' && lightboxImage.value) {
closeLightbox()
}
}
onMounted(() => {
loadImages()
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = 'auto' // Cleanup
})
</script>

86
components/Hero.vue Normal file
View File

@@ -0,0 +1,86 @@
<template>
<section id="home" class="relative min-h-full flex items-center justify-center overflow-hidden py-20 bg-gradient-to-br from-gray-50 to-gray-100">
<!-- Decorative Elements -->
<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" />
<div
class="absolute inset-0 opacity-5"
style="background-image: url('https://images.unsplash.com/photo-1609710228159-0fa9bd7c0827?q=80&w=2070'); background-size: cover; background-position: center;"
/>
</div>
<!-- Content -->
<div class="relative z-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-32">
<div class="text-center">
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-display font-bold text-gray-900 mb-6 leading-tight animate-fade-in">
Willkommen beim<br />
<span class="text-primary-600">Harheimer TC</span>
</h1>
<p class="text-xl sm:text-2xl text-gray-700 mb-8 max-w-3xl mx-auto animate-fade-in-delay-1">
Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit über 45 Jahren
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center animate-fade-in-delay-2">
<NuxtLink
to="/mitgliedschaft"
class="group px-8 py-4 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 flex items-center space-x-2"
>
<span>Mitglied werden</span>
<ArrowRight :size="20" class="group-hover:translate-x-1 transition-transform" />
</NuxtLink>
<NuxtLink
to="/kontakt"
class="px-8 py-4 bg-white hover:bg-gray-50 text-gray-900 font-semibold rounded-xl border-2 border-gray-300 hover:border-primary-600 shadow-lg transition-all duration-300"
>
Kontakt aufnehmen
</NuxtLink>
</div>
</div>
<!-- Termine -->
<div class="mt-16 max-w-4xl mx-auto">
<TermineVorschau />
</div>
</div>
<!-- Scroll Indicator -->
<div class="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-20 animate-bounce">
<div class="w-6 h-10 border-2 border-gray-400 rounded-full flex justify-center pt-2">
<div class="w-1.5 h-3 bg-primary-600 rounded-full" />
</div>
</div>
</section>
</template>
<script setup>
import { ArrowRight } from 'lucide-vue-next'
import TermineVorschau from './TermineVorschau.vue'
</script>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.8s ease-out;
}
.animate-fade-in-delay-1 {
animation: fadeIn 0.8s ease-out 0.2s both;
}
.animate-fade-in-delay-2 {
animation: fadeIn 0.8s ease-out 0.4s both;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div>
<div v-if="mannschaften.length > 0" class="space-y-8">
<div
v-for="(mannschaft, index) in mannschaften"
:key="index"
class="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden"
>
<!-- Header -->
<div class="bg-gradient-to-r from-primary-600 to-primary-700 p-6">
<h2 class="text-2xl font-display font-bold text-white mb-2">
{{ mannschaft.mannschaft }}
</h2>
<p class="text-primary-100 text-lg">{{ mannschaft.liga }}</p>
</div>
<!-- Content -->
<div class="p-6">
<!-- Liga-Info -->
<div class="grid md:grid-cols-2 gap-6 mb-6">
<div class="space-y-3">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<span class="text-gray-600">Staffelleiter:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.staffelleiter }}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<span class="text-gray-600">Telefon:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.telefon }}</span>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<span class="text-gray-600">Heimspieltag:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.heimspieltag }}</span>
</div>
<div class="flex items-center space-x-3">
<div class="w-2 h-2 bg-primary-600 rounded-full"></div>
<span class="text-gray-600">Spielsystem:</span>
<span class="font-semibold text-gray-900">{{ mannschaft.spielsystem }}</span>
</div>
</div>
</div>
<!-- Mannschaftsaufstellung -->
<div class="border-t border-gray-200 pt-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Mannschaftsaufstellung Saison 2025/26 (Hinrunde)
</h3>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="(spieler, spielerIndex) in getSpielerListe(mannschaft)"
:key="spielerIndex"
class="bg-gray-50 rounded-lg p-4 text-center"
:class="spieler === mannschaft.mannschaftsfuehrer ? 'ring-2 ring-primary-500 bg-primary-50' : ''"
>
<div class="font-semibold text-gray-900">{{ spieler }}</div>
<div v-if="spieler === mannschaft.mannschaftsfuehrer" class="text-xs text-primary-600 font-medium mt-1">
Mannschaftsführer
</div>
</div>
</div>
</div>
<!-- Links -->
<div class="border-t border-gray-200 pt-6 mt-6">
<div class="text-center">
<a
v-if="mannschaft.weitere_informationen_link && mannschaft.weitere_informationen_link !== ''"
:href="mannschaft.weitere_informationen_link"
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<BarChart :size="20" class="mr-2" />
Weitere Informationen
</a>
</div>
</div>
<!-- Letzte Aktualisierung -->
<div class="border-t border-gray-200 pt-4 mt-6">
<p class="text-sm text-gray-500 text-center">
Zuletzt aktualisiert am: {{ formatDate(mannschaft.letzte_aktualisierung) }}
</p>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 bg-gray-50 rounded-xl">
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
<p class="text-gray-600">Keine Mannschaftsdaten geladen</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Calendar, Users, BarChart } from 'lucide-vue-next'
const mannschaften = ref([])
const loadMannschaften = async () => {
try {
console.log('Lade Mannschaften...')
const response = await fetch('/data/mannschaften.csv')
console.log('Response:', response)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const csv = await response.text()
console.log('CSV Text:', csv)
// Vereinfachter CSV-Parser
const lines = csv.split('\n').filter(line => line.trim() !== '')
console.log('CSV Lines:', lines)
if (lines.length < 2) {
console.log('Keine Datenzeilen gefunden')
return
}
mannschaften.value = lines.slice(1).map((line, index) => {
// Besserer CSV-Parser: Respektiert Anführungszeichen
const values = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
if (values.length < 10) {
console.log(`Zeile ${index + 2} hat zu wenige Werte:`, values)
return null
}
const mannschaft = {
mannschaft: values[0].trim(),
liga: values[1].trim(),
staffelleiter: values[2].trim(),
telefon: values[3].trim(),
heimspieltag: values[4].trim(),
spielsystem: values[5].trim(),
mannschaftsfuehrer: values[6].trim(),
spieler: values[7].trim(),
weitere_informationen_link: values[8].trim(),
letzte_aktualisierung: values[9] ? values[9].trim() : ''
}
console.log(`Mannschaft ${index + 1}:`, mannschaft)
console.log(`Parsed values count: ${values.length}`)
console.log(`Letzte Aktualisierung raw: "${values[9]}"`)
console.log(`Letzte Aktualisierung trimmed: "${values[9] ? values[9].trim() : 'undefined'}")`)
return mannschaft
}).filter(mannschaft => mannschaft !== null)
console.log('Alle geparsten Mannschaften:', mannschaften.value)
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
}
}
const getSpielerListe = (mannschaft) => {
if (!mannschaft.spieler) return []
return mannschaft.spieler.split(';').map(s => s.trim()).filter(s => s !== '')
}
const formatDate = (dateString) => {
if (!dateString) return ''
// Wenn bereits im Format DD.MM.YYYY, direkt zurückgeben
if (/^\d{2}\.\d{2}\.\d{4}$/.test(dateString)) {
return dateString
}
// Versuche, das Datum zu parsen
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return dateString // Falls ungültig, Original zurückgeben
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
onMounted(() => {
loadMannschaften()
})
</script>

169
components/Membership.vue Normal file
View File

@@ -0,0 +1,169 @@
<template>
<section id="membership" class="py-16 sm:py-20 bg-gradient-to-b from-gray-50 to-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Mitgliedschaft
</h2>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Werden Sie Teil unserer Tischtennis-Familie - Wählen Sie die passende Mitgliedschaft für sich
</p>
</div>
<div class="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
<div
v-for="plan in plans"
:key="plan.name"
:class="[
'relative bg-white rounded-2xl shadow-xl overflow-hidden',
plan.popular ? 'ring-4 ring-primary-500 scale-105' : ''
]"
>
<div v-if="plan.popular" class="absolute top-0 right-0 bg-primary-600 text-white px-4 py-1 text-sm font-semibold rounded-bl-lg">
Beliebt
</div>
<div :class="['h-2 bg-gradient-to-r', plan.gradient]" />
<div class="p-8">
<div :class="['w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center mb-4', plan.gradient]">
<component :is="plan.icon" :size="24" class="text-white" />
</div>
<h3 class="text-2xl font-display font-bold text-gray-900 mb-2">
{{ plan.name }}
</h3>
<p class="text-gray-600 mb-6 min-h-[3rem]">
{{ plan.description }}
</p>
<div class="mb-6">
<div class="flex items-baseline">
<span class="text-5xl font-bold text-gray-900">{{ plan.price }}</span>
<span class="text-gray-600 ml-2">/ {{ plan.period }}</span>
</div>
</div>
<ul class="space-y-3 mb-8">
<li v-for="feature in plan.features" :key="feature" class="flex items-start">
<Check :size="20" class="text-primary-600 mr-3 flex-shrink-0 mt-0.5" />
<span class="text-gray-700">{{ feature }}</span>
</li>
</ul>
<NuxtLink
to="/kontakt"
:class="[
'block w-full text-center px-6 py-3 rounded-lg font-semibold transition-all duration-300',
plan.popular
? 'bg-primary-600 hover:bg-primary-700 text-white shadow-lg hover:shadow-xl'
: 'bg-gray-100 hover:bg-gray-200 text-gray-900'
]"
>
Jetzt beitreten
</NuxtLink>
</div>
</div>
</div>
<!-- Satzung Download -->
<div class="mt-16 bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div class="text-center mb-8">
<h3 class="text-3xl font-display font-bold text-gray-900 mb-4">
Vereinsatzung
</h3>
<p class="text-xl text-gray-600">
Laden Sie unsere aktuelle Vereinsatzung herunter
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a
href="/documents/satzung.pdf"
target="_blank"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
<FileText :size="20" class="mr-2" />
Satzung herunterladen (PDF)
</a>
<span class="text-sm text-gray-500">oder</span>
<NuxtLink
to="/satzung"
class="inline-flex items-center px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-900 font-semibold rounded-lg transition-colors"
>
<Eye :size="20" class="mr-2" />
Online ansehen
</NuxtLink>
</div>
</div>
<div class="mt-16 bg-gradient-to-r from-primary-600 to-primary-700 rounded-2xl p-8 sm:p-12 text-center">
<h3 class="text-3xl font-display font-bold text-white mb-4">
Noch Fragen zur Mitgliedschaft?
</h3>
<p class="text-xl text-primary-100 mb-6">
Kontaktieren Sie uns - wir beraten Sie gerne persönlich
</p>
<NuxtLink
to="/kontakt"
class="inline-flex items-center px-8 py-4 bg-white text-primary-600 font-semibold rounded-lg hover:bg-gray-100 transition-colors"
>
Jetzt Kontakt aufnehmen
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup>
import { Check, Star, Heart, FileText, Eye } from 'lucide-vue-next'
const plans = [
{
name: 'Kinder/Jugend',
price: '72',
period: 'Jahr',
description: 'Perfekt für junge Tischtennisspieler bis 18 Jahre',
features: [
'Unbegrenzte Hallennutzung',
'Kostenfreies Jugendtraining',
'Teilnahme an Jugendturnieren',
'Clubveranstaltungen',
'Gäste mitbringen',
],
icon: Star,
gradient: 'from-blue-500 to-cyan-500',
},
{
name: 'Erwachsene',
price: '120',
period: 'Jahr',
description: 'Vollmitgliedschaft für Erwachsene',
features: [
'Unbegrenzte Hallennutzung',
'Freies Spielen nach Verfügbarkeit',
'Clubveranstaltungen',
'Gäste mitbringen',
'Zugang Trainingsbereich',
],
icon: Check,
gradient: 'from-primary-500 to-green-600',
popular: true,
},
{
name: 'Passiv',
price: '30',
period: 'Jahr',
description: 'Unterstützen Sie Ihren Lieblingsverein',
features: [
'Vereinsunterstützung',
'Vereinsinformationen',
'Keine Spielberechtigung',
],
icon: Heart,
gradient: 'from-orange-500 to-red-500',
},
]
</script>

414
components/Navigation.vue Normal file
View File

@@ -0,0 +1,414 @@
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-gradient-to-r from-gray-900 via-primary-900 to-gray-900 shadow-xl h-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full">
<div class="flex flex-col justify-between h-full py-2">
<!-- Hauptmenü -->
<div class="flex justify-between items-center">
<!-- Logo -->
<NuxtLink to="/" class="flex items-center space-x-3 hover:scale-105 transition-transform">
<img
src="~/assets/images/logos/Harheimer TC.svg"
alt="Harheimer TC Logo"
class="w-12 h-12"
/>
<div class="hidden sm:block">
<span class="text-xl font-display font-bold text-white">Harheimer <span
class="text-primary-400">TC</span></span>
</div>
</NuxtLink>
<div style="display:flex;flex-direction:column;">
<!-- Desktop Navigation -->
<div class="hidden lg:flex items-center space-x-1">
<NuxtLink to="/"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
active-class="text-white bg-primary-600">
Start
</NuxtLink>
<button @click="toggleSubmenu('verein')"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
:class="(route.path.startsWith('/ueber-uns') || route.path.startsWith('/vorstand') || route.path.startsWith('/geschichte') || route.path.startsWith('/satzung') || route.path.startsWith('/vereinsmeisterschaften') || currentSubmenu === 'verein') ? 'text-white bg-primary-600' : ''">
Verein
</button>
<button @click="toggleSubmenu('mannschaften')"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
:class="(route.path.startsWith('/mannschaften') || route.path.startsWith('/spielsysteme') || currentSubmenu === 'mannschaften') ? 'text-white bg-primary-600' : ''">
Mannschaften
</button>
<button @click="toggleSubmenu('training')"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
:class="(route.path.startsWith('/training') || route.path.startsWith('/tt-regeln') || currentSubmenu === 'training') ? 'text-white bg-primary-600' : ''">
Training
</button>
<NuxtLink to="/mitgliedschaft"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
active-class="text-white bg-primary-600">
Mitgliedschaft
</NuxtLink>
<NuxtLink to="/termine" @click="currentSubmenu = null"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
active-class="text-white bg-primary-600">
Termine
</NuxtLink>
<NuxtLink
v-if="hasGalleryImages"
to="/galerie"
@click="currentSubmenu = null"
class="px-4 py-2 text-gray-300 hover:text-white font-medium transition-all rounded-lg hover:bg-primary-700/50"
active-class="text-white bg-primary-600">
Galerie
</NuxtLink>
<NuxtLink to="/kontakt" @click="currentSubmenu = null"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold transition-all rounded-lg shadow-lg">
Kontakt
</NuxtLink>
</div>
<div class="hidden lg:flex items-center h-6 border-t border-primary-700/20">
<div v-if="currentSubmenu" class="flex items-center space-x-1">
<!-- Verein Submenu -->
<template v-if="currentSubmenu === 'verein'">
<NuxtLink to="/ueber-uns"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Über uns
</NuxtLink>
<NuxtLink to="/vorstand"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Vorstand
</NuxtLink>
<NuxtLink to="/geschichte"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Geschichte
</NuxtLink>
<NuxtLink to="/satzung"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Satzung
</NuxtLink>
<NuxtLink to="/vereinsmeisterschaften"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Vereinsmeisterschaften
</NuxtLink>
</template>
<!-- Mannschaften Submenu -->
<template v-if="currentSubmenu === 'mannschaften'">
<NuxtLink to="/mannschaften"
class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
active-class="bg-primary-600">
Übersicht
</NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<template v-for="mannschaft in mannschaften" :key="mannschaft.slug">
<NuxtLink
:to="`/mannschaften/${mannschaft.slug}`"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
{{ mannschaft.mannschaft }}
</NuxtLink>
</template>
<div class="h-3 w-px bg-primary-700" />
<NuxtLink to="/mannschaften/spielplaene"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Spielpläne
</NuxtLink>
<NuxtLink to="/spielsysteme"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Spielsysteme
</NuxtLink>
</template>
<!-- Training Submenu -->
<template v-if="currentSubmenu === 'training'">
<NuxtLink to="/training"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Trainingszeiten
</NuxtLink>
<NuxtLink to="/training/trainer"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Trainer
</NuxtLink>
<NuxtLink to="/training/anfaenger"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
Anfänger
</NuxtLink>
<NuxtLink to="/tt-regeln"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600">
TT-Regeln
</NuxtLink>
</template>
</div>
</div>
<!-- Mobile Menu Button -->
<button @click="isMobileMenuOpen = !isMobileMenuOpen"
class="lg:hidden p-2 rounded-lg hover:bg-primary-700/50 transition-colors" aria-label="Toggle menu">
<X v-if="isMobileMenuOpen" :size="24" class="text-white" />
<Menu v-else :size="24" class="text-white" />
</button>
</div>
<!-- Untermenü (Desktop) - im gleichen Block, immer gleiche Höhe -->
</div>
</div>
</div>
<!-- Mobile Menu -->
<Transition enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 transform -translate-y-2" enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform -translate-y-2">
<div v-if="isMobileMenuOpen"
class="lg:hidden bg-gray-800 border-t border-primary-700/30 max-h-[80vh] overflow-y-auto">
<div class="px-4 py-4 space-y-2">
<NuxtLink to="/" @click="isMobileMenuOpen = false"
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Start
</NuxtLink>
<!-- Verein Mobile -->
<div>
<button @click="toggleMobileSubmenu('verein')"
class="w-full flex items-center justify-between px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Verein
<ChevronDown :size="16"
:class="['transition-transform', mobileSubmenu === 'verein' ? 'rotate-180' : '']" />
</button>
<div v-if="mobileSubmenu === 'verein'" class="pl-4 space-y-1 mt-1 bg-primary-900/30 rounded-lg p-2">
<NuxtLink to="/ueber-uns" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Über uns
</NuxtLink>
<NuxtLink to="/vorstand" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Vorstand
</NuxtLink>
<NuxtLink to="/geschichte" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Geschichte
</NuxtLink>
<NuxtLink to="/satzung" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Satzung
</NuxtLink>
<NuxtLink to="/vereinsmeisterschaften" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Vereinsmeisterschaften
</NuxtLink>
</div>
</div>
<!-- Mannschaften Mobile -->
<div>
<button @click="toggleMobileSubmenu('mannschaften')"
class="w-full flex items-center justify-between px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Mannschaften
<ChevronDown :size="16"
:class="['transition-transform', mobileSubmenu === 'mannschaften' ? 'rotate-180' : '']" />
</button>
<div v-if="mobileSubmenu === 'mannschaften'" class="pl-4 space-y-1 mt-1 bg-primary-900/30 rounded-lg p-2">
<NuxtLink to="/mannschaften" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm font-semibold text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Übersicht
</NuxtLink>
<template v-for="mannschaft in mannschaften" :key="mannschaft.slug">
<NuxtLink
:to="`/mannschaften/${mannschaft.slug}`"
@click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
{{ mannschaft.mannschaft }}
</NuxtLink>
</template>
<div class="border-t border-primary-700/20 my-2" />
<NuxtLink to="/mannschaften/spielplaene" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Spielpläne
</NuxtLink>
<NuxtLink to="/spielsysteme" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Spielsysteme
</NuxtLink>
</div>
</div>
<!-- Training Mobile -->
<div>
<button @click="toggleMobileSubmenu('training')"
class="w-full flex items-center justify-between px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Training
<ChevronDown :size="16"
:class="['transition-transform', mobileSubmenu === 'training' ? 'rotate-180' : '']" />
</button>
<div v-if="mobileSubmenu === 'training'" class="pl-4 space-y-1 mt-1 bg-primary-900/30 rounded-lg p-2">
<NuxtLink to="/training" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Trainingszeiten
</NuxtLink>
<NuxtLink to="/training/trainer" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Trainer
</NuxtLink>
<NuxtLink to="/training/anfaenger" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
Anfänger
</NuxtLink>
<NuxtLink to="/tt-regeln" @click="isMobileMenuOpen = false"
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors">
TT-Regeln
</NuxtLink>
</div>
</div>
<NuxtLink to="/mitgliedschaft" @click="isMobileMenuOpen = false"
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Mitgliedschaft
</NuxtLink>
<NuxtLink to="/termine" @click="isMobileMenuOpen = false"
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Termine
</NuxtLink>
<NuxtLink
v-if="hasGalleryImages"
to="/galerie"
@click="isMobileMenuOpen = false"
class="block px-4 py-3 text-gray-300 hover:text-white hover:bg-primary-700/50 rounded-lg font-medium transition-colors">
Galerie
</NuxtLink>
<NuxtLink to="/kontakt" @click="isMobileMenuOpen = false"
class="block px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-semibold transition-colors">
Kontakt
</NuxtLink>
</div>
</div>
</Transition>
</nav>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Menu, X, ChevronDown } from 'lucide-vue-next'
const route = useRoute()
const isMobileMenuOpen = ref(false)
const mobileSubmenu = ref(null)
const mannschaften = ref([])
const hasGalleryImages = ref(false)
// Automatisches Setzen des Submenus basierend auf der Route
const currentSubmenu = computed(() => {
const path = route.path
if (path.startsWith('/ueber-uns') || path.startsWith('/vorstand') ||
path.startsWith('/geschichte') || path.startsWith('/satzung') ||
path.startsWith('/vereinsmeisterschaften')) {
return 'verein'
}
if (path.startsWith('/mannschaften') || path.startsWith('/spielsysteme')) {
return 'mannschaften'
}
if (path.startsWith('/training') || path.startsWith('/tt-regeln')) {
return 'training'
}
return null
})
// Manuelles Toggle für Click-Events
const manualSubmenu = ref(null)
const toggleMobileSubmenu = (menu) => {
mobileSubmenu.value = mobileSubmenu.value === menu ? null : menu
}
const loadMannschaften = async () => {
try {
const response = await fetch('/data/mannschaften.csv')
if (!response.ok) return
const csv = await response.text()
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) return
mannschaften.value = lines.slice(1).map(line => {
// Besserer CSV-Parser: Respektiert Anführungszeichen
const values = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
if (values.length < 10) return null
return {
mannschaft: values[0].trim(),
slug: values[0].trim().toLowerCase().replace(/\s+/g, '-')
}
}).filter(mannschaft => mannschaft !== null)
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
}
}
const checkGalleryImages = async () => {
try {
const response = await $fetch('/api/galerie')
hasGalleryImages.value = response && response.length > 0
} catch (error) {
console.error('Fehler beim Prüfen der Galerie-Bilder:', error)
hasGalleryImages.value = false
}
}
onMounted(() => {
loadMannschaften()
checkGalleryImages()
})
const toggleSubmenu = (menu) => {
// Wenn wir schon im richtigen Bereich sind, nichts tun (Submenu bleibt offen)
// Wenn nicht, zur Hauptseite navigieren
const path = route.path
if (menu === 'verein' && !path.startsWith('/ueber-uns') && !path.startsWith('/vorstand') &&
!path.startsWith('/geschichte') && !path.startsWith('/satzung') && !path.startsWith('/vereinsmeisterschaften')) {
navigateTo('/ueber-uns')
} else if (menu === 'mannschaften' && !path.startsWith('/mannschaften') && !path.startsWith('/spielsysteme')) {
navigateTo('/mannschaften')
} else if (menu === 'training' && !path.startsWith('/training') && !path.startsWith('/tt-regeln')) {
navigateTo('/training')
}
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<div>
<div class="text-center mb-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-2">
Kommende Termine
</h2>
<div class="w-16 h-0.5 bg-primary-600 mx-auto" />
</div>
<div v-if="naechsteTermine.length > 0" class="space-y-2 mb-6">
<div
v-for="(termin, index) in naechsteTermine"
:key="index"
class="bg-gray-50 rounded-lg p-3 hover:bg-gray-100 transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-primary-600 rounded-lg flex flex-col items-center justify-center text-white text-xs font-bold">
<span>{{ formatDay(termin.datum) }}</span>
<span>{{ formatMonth(termin.datum) }}</span>
</div>
<div>
<h3 class="font-semibold text-gray-900">{{ termin.titel }}</h3>
<p class="text-sm text-gray-600">{{ termin.beschreibung }}</p>
</div>
</div>
<span :class="[
'px-2 py-1 text-xs font-medium rounded-full',
termin.kategorie === 'Turnier' ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'
]">
{{ termin.kategorie }}
</span>
</div>
</div>
</div>
<div v-else class="text-center py-8 bg-gray-50 rounded-lg">
<Calendar :size="32" class="text-gray-400 mx-auto mb-2" />
<p class="text-gray-600 text-sm">Keine kommenden Termine</p>
</div>
<div v-if="naechsteTermine.length > 0" class="text-center">
<NuxtLink
to="/termine"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Alle Termine anzeigen
<ArrowRight :size="16" class="ml-1" />
</NuxtLink>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Calendar, ArrowRight } from 'lucide-vue-next'
const termine = ref([])
const naechsteTermine = computed(() => {
const heute = new Date()
console.log('Heute ist:', heute.toISOString().split('T')[0])
const kommende = termine.value
.filter(t => {
const terminDatum = new Date(t.datum)
const istKommend = terminDatum >= heute
console.log(`Termin ${t.titel} (${t.datum}): ${istKommend ? 'KOMMEND' : 'VERSTRICHEN'}`)
return istKommend
})
.sort((a, b) => new Date(a.datum) - new Date(b.datum))
console.log('Kommende Termine:', kommende)
return kommende
})
const formatDay = (dateString) => {
const date = new Date(dateString)
return date.getDate()
}
const formatMonth = (dateString) => {
const date = new Date(dateString)
const monate = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
return monate[date.getMonth()]
}
const loadTermine = async () => {
try {
console.log('Lade Termine...')
const response = await fetch('/data/termine.csv')
console.log('Response:', response)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const csv = await response.text()
console.log('CSV Text:', csv)
// Vereinfachter CSV-Parser
const lines = csv.split('\n').filter(line => line.trim() !== '')
console.log('CSV Lines:', lines)
if (lines.length < 2) {
console.log('Keine Datenzeilen gefunden')
return
}
termine.value = lines.slice(1).map((line, index) => {
// Entferne Anführungszeichen und teile bei Kommas
const cleanLine = line.replace(/"/g, '')
const values = cleanLine.split(',')
if (values.length < 4) {
console.log(`Zeile ${index + 2} hat zu wenige Werte:`, values)
return null
}
const termin = {
datum: values[0].trim(),
titel: values[1].trim(),
beschreibung: values[2].trim(),
kategorie: values[3].trim()
}
console.log(`Termin ${index + 1}:`, termin)
return termin
}).filter(termin => termin !== null)
console.log('Alle geparsten Termine:', termine.value)
} catch (error) {
console.error('Fehler beim Laden der Termine:', error)
}
}
onMounted(() => {
loadTermine()
})
</script>