Compare commits
10 Commits
2d974214ab
...
backup/rem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fcf6ced0e | ||
|
|
9c1bcba713 | ||
|
|
74b28bbc49 | ||
|
|
905e02debf | ||
|
|
80c2b0bfeb | ||
|
|
33ef5cda5f | ||
|
|
f7fe8595a1 | ||
|
|
581e80bbc3 | ||
|
|
78aec7ce57 | ||
|
|
7346e84abd |
@@ -18,12 +18,12 @@
|
|||||||
<div class="grid sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
<div class="grid sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.filename"
|
:key="image.id"
|
||||||
class="group relative w-20 h-20 rounded-md overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 cursor-pointer"
|
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)"
|
@click="openLightbox(image)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="`/galerie/${image.filename}`"
|
:src="getPreviewUrl(image)"
|
||||||
:alt="image.title"
|
:alt="image.title"
|
||||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||||
>
|
>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<X :size="24" />
|
<X :size="24" />
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img
|
||||||
:src="`/galerie/${lightboxImage.filename}`"
|
:src="getOriginalUrl(lightboxImage)"
|
||||||
:alt="lightboxImage.title"
|
:alt="lightboxImage.title"
|
||||||
class="max-w-[80vw] max-h-[80vh] object-contain rounded-lg"
|
class="max-w-[80vw] max-h-[80vh] object-contain rounded-lg"
|
||||||
@click.stop
|
@click.stop
|
||||||
@@ -66,16 +66,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
const images = ref([])
|
const images = ref([])
|
||||||
const lightboxImage = ref(null)
|
const lightboxImage = ref(null)
|
||||||
|
|
||||||
|
const getPreviewUrl = (img) => `/api/media/galerie/${img.id}?preview=true`
|
||||||
|
const getOriginalUrl = (img) => `/api/media/galerie/${img.id}`
|
||||||
|
|
||||||
const loadImages = async () => {
|
const loadImages = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/galerie')
|
const response = await $fetch('/api/galerie/list')
|
||||||
images.value = response || []
|
images.value = response?.images || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Galerie-Bilder:', error)
|
console.error('Fehler beim Laden der Galerie-Bilder:', error)
|
||||||
images.value = []
|
images.value = []
|
||||||
|
|||||||
@@ -336,32 +336,11 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="border-t border-gray-700 my-1" />
|
<div class="border-t border-gray-700 my-1" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/ueber-uns"
|
to="/cms/inhalte"
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||||
@click="showCmsDropdown = false"
|
@click="showCmsDropdown = false"
|
||||||
>
|
>
|
||||||
Über uns
|
Inhalte
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/geschichte"
|
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
|
||||||
@click="showCmsDropdown = false"
|
|
||||||
>
|
|
||||||
Geschichte
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/tt-regeln"
|
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
|
||||||
@click="showCmsDropdown = false"
|
|
||||||
>
|
|
||||||
TT-Regeln
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/satzung"
|
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
|
||||||
@click="showCmsDropdown = false"
|
|
||||||
>
|
|
||||||
Satzung
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/vereinsmeisterschaften"
|
to="/cms/vereinsmeisterschaften"
|
||||||
@@ -379,32 +358,18 @@
|
|||||||
News
|
News
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/termine"
|
to="/cms/sportbetrieb"
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||||
@click="showCmsDropdown = false"
|
@click="showCmsDropdown = false"
|
||||||
>
|
>
|
||||||
Termine
|
Sportbetrieb
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/mannschaften"
|
to="/cms/mitgliederverwaltung"
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||||
@click="showCmsDropdown = false"
|
@click="showCmsDropdown = false"
|
||||||
>
|
>
|
||||||
Mannschaften
|
Mitgliederverwaltung
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/spielplaene"
|
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
|
||||||
@click="showCmsDropdown = false"
|
|
||||||
>
|
|
||||||
Spielpläne
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/mitgliederbereich/mitglieder"
|
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
|
||||||
@click="showCmsDropdown = false"
|
|
||||||
>
|
|
||||||
Mitglieder
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="border-t border-gray-700 my-1" />
|
<div class="border-t border-gray-700 my-1" />
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -414,13 +379,6 @@
|
|||||||
>
|
>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
|
||||||
to="/cms/mitgliedschaftsantraege"
|
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
|
||||||
@click="showCmsDropdown = false"
|
|
||||||
>
|
|
||||||
Mitgliedschaftsanträge
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/benutzer"
|
to="/cms/benutzer"
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||||
@@ -773,60 +731,25 @@
|
|||||||
News
|
News
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/termine"
|
to="/cms/sportbetrieb"
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
@click="isMobileMenuOpen = false"
|
@click="isMobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
Termine
|
Sportbetrieb
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/mannschaften"
|
to="/cms/mitgliederverwaltung"
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
@click="isMobileMenuOpen = false"
|
@click="isMobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
Mannschaften
|
Mitgliederverwaltung
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/spielplaene"
|
to="/cms/inhalte"
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||||
@click="isMobileMenuOpen = false"
|
@click="isMobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
Spielpläne
|
Inhalte
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/mitgliederbereich/mitglieder"
|
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
Mitglieder
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/ueber-uns"
|
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
Über uns
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/geschichte"
|
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
Geschichte
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/tt-regeln"
|
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
TT-Regeln
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/satzung"
|
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
Satzung
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/vereinsmeisterschaften"
|
to="/cms/vereinsmeisterschaften"
|
||||||
@@ -842,13 +765,6 @@
|
|||||||
>
|
>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
|
||||||
to="/cms/mitgliedschaftsantraege"
|
|
||||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
|
||||||
@click="isMobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
Mitgliedschaftsanträge
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
|
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
|
||||||
to="/cms/benutzer"
|
to="/cms/benutzer"
|
||||||
|
|||||||
@@ -1,145 +1,130 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-full bg-gray-50">
|
<div>
|
||||||
<!-- Fixed Header below navigation -->
|
<!-- Header with save button -->
|
||||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||||
<div class="flex items-center justify-between">
|
Geschichte bearbeiten
|
||||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
</h2>
|
||||||
Geschichte bearbeiten
|
<button
|
||||||
</h1>
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||||
<div class="space-x-3">
|
@click="save"
|
||||||
<button
|
>
|
||||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
Speichern
|
||||||
@click="save"
|
</button>
|
||||||
>
|
</div>
|
||||||
Speichern
|
|
||||||
</button>
|
<!-- Toolbar -->
|
||||||
</div>
|
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||||
|
<!-- Formatierung -->
|
||||||
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('bold')"
|
||||||
|
>
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('italic')"
|
||||||
|
>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(1)"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(2)"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(3)"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Listen -->
|
||||||
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('insertUnorderedList')"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('insertOrderedList')"
|
||||||
|
>
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schnellzugriff für Geschichts-Abschnitte -->
|
||||||
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
|
||||||
|
@click="insertHistoryTemplate('generic')"
|
||||||
|
>
|
||||||
|
Neuer Abschnitt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
|
||||||
|
@click="insertHistoryTemplate('founding')"
|
||||||
|
>
|
||||||
|
Gründung
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
|
||||||
|
@click="insertHistoryTemplate('milestone')"
|
||||||
|
>
|
||||||
|
Meilenstein
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
|
||||||
|
@click="insertHistoryTemplate('achievement')"
|
||||||
|
>
|
||||||
|
Erfolg
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
|
||||||
|
@click="deleteCurrentSection()"
|
||||||
|
>
|
||||||
|
Abschnitt löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weitere Tools -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="createLink()"
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="removeFormat()"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fixed Toolbar below header -->
|
<!-- Editor -->
|
||||||
<div
|
<div class="bg-white rounded-b-lg shadow-sm border border-t-0 border-gray-200 p-3 sm:p-4">
|
||||||
class="fixed left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm"
|
<div
|
||||||
style="top: 9.5rem;"
|
ref="editor"
|
||||||
>
|
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
contenteditable
|
||||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
|
/>
|
||||||
<!-- Formatierung -->
|
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('bold')"
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('italic')"
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(1)"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(2)"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(3)"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Listen -->
|
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('insertUnorderedList')"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('insertOrderedList')"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schnellzugriff für Geschichts-Abschnitte -->
|
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
|
|
||||||
@click="insertHistoryTemplate('generic')"
|
|
||||||
>
|
|
||||||
Neuer Abschnitt
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
|
|
||||||
@click="insertHistoryTemplate('founding')"
|
|
||||||
>
|
|
||||||
Gründung
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
|
|
||||||
@click="insertHistoryTemplate('milestone')"
|
|
||||||
>
|
|
||||||
Meilenstein
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
|
|
||||||
@click="insertHistoryTemplate('achievement')"
|
|
||||||
>
|
|
||||||
Erfolg
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
|
|
||||||
@click="deleteCurrentSection()"
|
|
||||||
>
|
|
||||||
Abschnitt löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Weitere Tools -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="createLink()"
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="removeFormat()"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content with top padding -->
|
|
||||||
<div class="pt-36 sm:pt-44 pb-16">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
|
||||||
<div
|
|
||||||
ref="editor"
|
|
||||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
|
||||||
contenteditable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,19 +132,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ title: 'CMS: Geschichte' })
|
|
||||||
|
|
||||||
const editor = ref(null)
|
const editor = ref(null)
|
||||||
const initialHtml = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await $fetch('/api/config')
|
const data = await $fetch('/api/config')
|
||||||
initialHtml.value = data?.seiten?.geschichte || ''
|
const html = data?.seiten?.geschichte || ''
|
||||||
if (editor.value) editor.value.innerHTML = initialHtml.value
|
if (editor.value) editor.value.innerHTML = html
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -169,11 +147,11 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
|
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
|
||||||
// Modal nicht verfügbar, ignorieren
|
// Modal nicht verfügbar
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
||||||
// Modal nicht verfügbar, ignorieren
|
// Modal nicht verfügbar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,24 +219,19 @@ function insertHistoryTemplate(type) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editor fokussieren
|
|
||||||
editorElement.focus()
|
editorElement.focus()
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection.rangeCount > 0) {
|
if (selection.rangeCount > 0) {
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
|
||||||
// Prüfen ob der Cursor im Editor ist
|
|
||||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||||
// Aktuelles Element finden
|
|
||||||
let currentElement = range.commonAncestorContainer
|
let currentElement = range.commonAncestorContainer
|
||||||
|
|
||||||
// Falls es ein Text-Node ist, zum Parent-Element gehen
|
|
||||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
if (currentElement.nodeType === Node.TEXT_NODE) {
|
||||||
currentElement = currentElement.parentElement
|
currentElement = currentElement.parentElement
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zum Geschichts-Abschnitt navigieren (div mit border-l-4 border-primary-600)
|
|
||||||
let sectionElement = currentElement
|
let sectionElement = currentElement
|
||||||
while (sectionElement && sectionElement !== editorElement) {
|
while (sectionElement && sectionElement !== editorElement) {
|
||||||
if (sectionElement.classList &&
|
if (sectionElement.classList &&
|
||||||
@@ -273,11 +246,9 @@ function insertHistoryTemplate(type) {
|
|||||||
sectionElement.classList.contains('border-l-4') &&
|
sectionElement.classList.contains('border-l-4') &&
|
||||||
sectionElement.classList.contains('border-primary-600')) {
|
sectionElement.classList.contains('border-primary-600')) {
|
||||||
|
|
||||||
// Wir sind in einem Geschichts-Abschnitt - neuen Abschnitt danach einfügen
|
|
||||||
const tempDiv = document.createElement('div')
|
const tempDiv = document.createElement('div')
|
||||||
tempDiv.innerHTML = template
|
tempDiv.innerHTML = template
|
||||||
|
|
||||||
// Suche nach dem ersten Element-Node (nicht Text-Node)
|
|
||||||
let newSection = null
|
let newSection = null
|
||||||
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
||||||
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
|
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
|
||||||
@@ -287,14 +258,12 @@ function insertHistoryTemplate(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newSection) {
|
if (newSection) {
|
||||||
// Nach dem aktuellen Abschnitt einfügen
|
|
||||||
if (sectionElement.nextSibling) {
|
if (sectionElement.nextSibling) {
|
||||||
sectionElement.parentElement.insertBefore(newSection, sectionElement.nextSibling)
|
sectionElement.parentElement.insertBefore(newSection, sectionElement.nextSibling)
|
||||||
} else {
|
} else {
|
||||||
sectionElement.parentElement.appendChild(newSection)
|
sectionElement.parentElement.appendChild(newSection)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor in das neue Element setzen
|
|
||||||
const newRange = document.createRange()
|
const newRange = document.createRange()
|
||||||
const titleElement = newSection.querySelector('h3')
|
const titleElement = newSection.querySelector('h3')
|
||||||
if (titleElement) {
|
if (titleElement) {
|
||||||
@@ -303,13 +272,10 @@ function insertHistoryTemplate(type) {
|
|||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(newRange)
|
selection.addRange(newRange)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.error('No valid element found in template');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Kein Geschichts-Abschnitt gefunden - suche nach dem nächsten Geschichts-Abschnitt
|
|
||||||
let nextSection = null
|
let nextSection = null
|
||||||
let walker = document.createTreeWalker(
|
const walker = document.createTreeWalker(
|
||||||
editorElement,
|
editorElement,
|
||||||
NodeFilter.SHOW_ELEMENT,
|
NodeFilter.SHOW_ELEMENT,
|
||||||
{
|
{
|
||||||
@@ -324,7 +290,6 @@ function insertHistoryTemplate(type) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finde den ersten Geschichts-Abschnitt nach dem aktuellen Element
|
|
||||||
let node = walker.nextNode()
|
let node = walker.nextNode()
|
||||||
while (node) {
|
while (node) {
|
||||||
if (currentElement.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
if (currentElement.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||||
@@ -337,7 +302,6 @@ function insertHistoryTemplate(type) {
|
|||||||
const tempDiv = document.createElement('div')
|
const tempDiv = document.createElement('div')
|
||||||
tempDiv.innerHTML = template
|
tempDiv.innerHTML = template
|
||||||
|
|
||||||
// Suche nach dem ersten Element-Node (nicht Text-Node)
|
|
||||||
let newSection = null
|
let newSection = null
|
||||||
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
||||||
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
|
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
|
||||||
@@ -348,14 +312,11 @@ function insertHistoryTemplate(type) {
|
|||||||
|
|
||||||
if (newSection) {
|
if (newSection) {
|
||||||
if (nextSection) {
|
if (nextSection) {
|
||||||
// Vor dem nächsten Geschichts-Abschnitt einfügen
|
|
||||||
nextSection.parentElement.insertBefore(newSection, nextSection)
|
nextSection.parentElement.insertBefore(newSection, nextSection)
|
||||||
} else {
|
} else {
|
||||||
// Kein nächster Abschnitt gefunden - am Ende einfügen
|
|
||||||
editorElement.appendChild(newSection)
|
editorElement.appendChild(newSection)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor in das neue Element setzen
|
|
||||||
const newRange = document.createRange()
|
const newRange = document.createRange()
|
||||||
const titleElement = newSection.querySelector('h3')
|
const titleElement = newSection.querySelector('h3')
|
||||||
if (titleElement) {
|
if (titleElement) {
|
||||||
@@ -364,16 +325,12 @@ function insertHistoryTemplate(type) {
|
|||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
selection.addRange(newRange)
|
selection.addRange(newRange)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.error('No valid element found in template');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Cursor ist nicht im Editor - Template am Ende einfügen
|
|
||||||
editorElement.innerHTML += template
|
editorElement.innerHTML += template
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Keine Auswahl - Template am Ende einfügen
|
|
||||||
editorElement.innerHTML += template
|
editorElement.innerHTML += template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,34 +339,27 @@ function deleteCurrentSection() {
|
|||||||
const editorElement = editor.value
|
const editorElement = editor.value
|
||||||
if (!editorElement) return
|
if (!editorElement) return
|
||||||
|
|
||||||
// Editor fokussieren
|
|
||||||
editorElement.focus()
|
editorElement.focus()
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (selection.rangeCount > 0) {
|
if (selection.rangeCount > 0) {
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
|
||||||
// Prüfen ob der Cursor im Editor ist
|
|
||||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||||
// Aktuelles Element finden
|
|
||||||
let currentElement = range.commonAncestorContainer
|
let currentElement = range.commonAncestorContainer
|
||||||
|
|
||||||
// Falls es ein Text-Node ist, zum Parent-Element gehen
|
|
||||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
if (currentElement.nodeType === Node.TEXT_NODE) {
|
||||||
currentElement = currentElement.parentElement
|
currentElement = currentElement.parentElement
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zum Geschichts-Abschnitt navigieren (div mit border-l-4 border-primary-600)
|
|
||||||
let sectionElement = currentElement
|
let sectionElement = currentElement
|
||||||
while (sectionElement && !(sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600'))) {
|
while (sectionElement && !(sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600'))) {
|
||||||
sectionElement = sectionElement.parentElement
|
sectionElement = sectionElement.parentElement
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sectionElement && sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600')) {
|
if (sectionElement && sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600')) {
|
||||||
// Geschichts-Abschnitt gefunden - löschen
|
|
||||||
sectionElement.remove()
|
sectionElement.remove()
|
||||||
|
|
||||||
// Cursor in das nächste Element setzen
|
|
||||||
const nextElement = editorElement.querySelector('.border-l-4.border-primary-600')
|
const nextElement = editorElement.querySelector('.border-l-4.border-primary-600')
|
||||||
if (nextElement) {
|
if (nextElement) {
|
||||||
const titleElement = nextElement.querySelector('h3')
|
const titleElement = nextElement.querySelector('h3')
|
||||||
245
components/cms/CmsMannschaften.vue
Normal file
245
components/cms/CmsMannschaften.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2>
|
||||||
|
<div class="w-24 h-1 bg-primary-600" />
|
||||||
|
</div>
|
||||||
|
<button class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">
|
||||||
|
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div>
|
||||||
|
|
||||||
|
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||||
|
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button>
|
||||||
|
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center">
|
||||||
|
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3>
|
||||||
|
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p>
|
||||||
|
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6 border-b border-gray-200">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2>
|
||||||
|
</div>
|
||||||
|
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
|
||||||
|
<input v-model="formData.mannschaft" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
|
||||||
|
<input v-model="formData.liga" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
|
||||||
|
<input v-model="formData.staffelleiter" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
|
||||||
|
<input v-model="formData.telefon" type="tel" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
|
||||||
|
<input v-model="formData.heimspieltag" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
|
||||||
|
<input v-model="formData.spielsystem" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
|
||||||
|
<input v-model="formData.mannschaftsfuehrer" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div>
|
||||||
|
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white">
|
||||||
|
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||||
|
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button>
|
||||||
|
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button>
|
||||||
|
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen">
|
||||||
|
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button>
|
||||||
|
<p class="text-xs text-gray-500">Reihenfolge per ↑/↓ ändern.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
|
||||||
|
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"><AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}</div>
|
||||||
|
<div class="flex justify-end space-x-4 pt-4">
|
||||||
|
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"><Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const mannschaften = ref([])
|
||||||
|
const showModal = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const editingIndex = ref(-1)
|
||||||
|
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
|
||||||
|
const moveTargetBySpielerId = ref({})
|
||||||
|
const pendingSpielerNamesByTeamIndex = ref({})
|
||||||
|
|
||||||
|
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
|
||||||
|
function newSpielerItem(name = '') { return { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, name } }
|
||||||
|
function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) }
|
||||||
|
function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') }
|
||||||
|
function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') }
|
||||||
|
|
||||||
|
async function fetchCsvText(url) {
|
||||||
|
const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() }
|
||||||
|
try { return await attempt() } catch { await new Promise(r => setTimeout(r, 150)); return await attempt() }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mannschaftenSelectOptions = computed(() => {
|
||||||
|
const current = (formData.value.mannschaft || '').trim()
|
||||||
|
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
|
||||||
|
return [...new Set([current, ...names])].filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||||
|
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||||
|
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||||
|
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
|
||||||
|
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]; return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMannschaften = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const csv = await fetchCsvText('/api/mannschaften')
|
||||||
|
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||||
|
if (lines.length < 2) { mannschaften.value = []; return }
|
||||||
|
mannschaften.value = lines.slice(1).map(line => {
|
||||||
|
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() || '', 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]?.trim() || '' }
|
||||||
|
}).filter(m => m !== null && m.mannschaft !== '')
|
||||||
|
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
|
||||||
|
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
|
||||||
|
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
|
||||||
|
const openEditModal = (mannschaft, index) => {
|
||||||
|
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
|
||||||
|
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
|
||||||
|
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||||
|
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
|
||||||
|
}
|
||||||
|
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
|
||||||
|
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
|
||||||
|
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
|
||||||
|
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
|
||||||
|
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
|
||||||
|
|
||||||
|
const moveSpielerToMannschaft = (spielerId) => {
|
||||||
|
if (!isEditing.value || editingIndex.value < 0) return
|
||||||
|
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
|
||||||
|
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
|
||||||
|
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
|
||||||
|
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
|
||||||
|
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
|
||||||
|
formData.value.spielerListe.splice(idx, 1)
|
||||||
|
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
|
||||||
|
delete moveTargetBySpielerId.value[spielerId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMannschaft = async () => {
|
||||||
|
isSaving.value = true; errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
||||||
|
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
|
||||||
|
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
|
||||||
|
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
|
||||||
|
if (touchedTeamIndexes.length > 0) { const ts = nowIsoDate(); for (const idxStr of touchedTeamIndexes) { const idx = Number(idxStr); if (!Number.isFinite(idx)) continue; const existing = mannschaften.value[idx]; if (!existing) continue; mannschaften.value[idx] = { ...existing, spieler: serializeSpielerNames(pendingSpielerNamesByTeamIndex.value[idx]), letzte_aktualisierung: ts } } }
|
||||||
|
await saveCSV(); closeModal(); await loadMannschaften()
|
||||||
|
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
|
||||||
|
} catch (error) { console.error('Fehler:', error); errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern.'; if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value) } finally { isSaving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCSV = async () => {
|
||||||
|
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
|
||||||
|
const rows = mannschaften.value.map(m => {
|
||||||
|
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
|
||||||
|
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',')
|
||||||
|
})
|
||||||
|
await $fetch('/api/cms/save-csv', { method: 'POST', body: { filename: 'mannschaften.csv', content: [header, ...rows].join('\n') } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (mannschaft, index) => {
|
||||||
|
if (window.showConfirmModal) {
|
||||||
|
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
|
||||||
|
try { mannschaften.value.splice(index, 1); await saveCSV(); await loadMannschaften(); window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht') } catch (error) { console.error('Fehler:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft') }
|
||||||
|
})
|
||||||
|
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadMannschaften().catch(() => {}) })
|
||||||
|
</script>
|
||||||
936
components/cms/CmsMitglieder.vue
Normal file
936
components/cms/CmsMitglieder.vue
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Mitgliederliste
|
||||||
|
</h2>
|
||||||
|
<div class="w-24 h-1 bg-primary-600 mb-4" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
class="flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold rounded-lg transition-colors"
|
||||||
|
@click="viewMode = viewMode === 'cards' ? 'table' : 'cards'"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="viewMode === 'cards' ? Table2 : Grid3x3"
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ viewMode === 'cards' ? 'Tabelle' : 'Karten' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
@click="showBulkImportModal = true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Bulk-Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
@click="openAddModal"
|
||||||
|
>
|
||||||
|
<UserPlus
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
Mitglied hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-12"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
:size="40"
|
||||||
|
class="animate-spin text-primary-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table View -->
|
||||||
|
<div
|
||||||
|
v-else-if="viewMode === 'table'"
|
||||||
|
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
E-Mail
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Telefon
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Mannschaft
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-if="canEdit"
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="member in members"
|
||||||
|
:key="member.id"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
{{ member.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="member.notes"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
{{ member.notes }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<template v-if="canViewContactData">
|
||||||
|
<a
|
||||||
|
v-if="member.email"
|
||||||
|
:href="`mailto:${member.email}`"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-800"
|
||||||
|
>
|
||||||
|
{{ member.email }}
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-gray-400"
|
||||||
|
>-</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-gray-400"
|
||||||
|
>Nur für Vorstand</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<template v-if="canViewContactData">
|
||||||
|
<a
|
||||||
|
v-if="member.phone"
|
||||||
|
:href="`tel:${member.phone}`"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-800"
|
||||||
|
>
|
||||||
|
{{ member.phone }}
|
||||||
|
</a>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-gray-400"
|
||||||
|
>-</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-sm text-gray-400"
|
||||||
|
>Nur für Vorstand</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full transition-colors',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
title="Klicken zum Umschalten"
|
||||||
|
@click="toggleMannschaftsspieler(member)"
|
||||||
|
>
|
||||||
|
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
v-if="member.hasLogin"
|
||||||
|
class="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="member.source === 'manual' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'"
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
{{ member.source === 'manual' ? 'Manuell' : 'System' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="canEdit"
|
||||||
|
class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="member.editable"
|
||||||
|
class="flex justify-end space-x-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="text-blue-600 hover:text-blue-900"
|
||||||
|
title="Bearbeiten"
|
||||||
|
@click="openEditModal(member)"
|
||||||
|
>
|
||||||
|
<Edit :size="18" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
title="Löschen"
|
||||||
|
@click="confirmDelete(member)"
|
||||||
|
>
|
||||||
|
<Trash2 :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-gray-400 text-xs"
|
||||||
|
>Nicht editierbar</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="members.length === 0"
|
||||||
|
class="text-center py-12 text-gray-500"
|
||||||
|
>
|
||||||
|
Keine Mitglieder gefunden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards View -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="member in members"
|
||||||
|
:key="member.id"
|
||||||
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
{{ member.name }}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
v-if="member.hasLogin"
|
||||||
|
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
Hat Login
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="member.source === 'manual'"
|
||||||
|
class="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
Manuell
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
Aus Login-System
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
:class="[
|
||||||
|
'ml-2 px-2 py-1 text-xs font-medium rounded-full transition-colors',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
title="Klicken zum Umschalten"
|
||||||
|
@click="toggleMannschaftsspieler(member)"
|
||||||
|
>
|
||||||
|
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'ml-2 px-2 py-1 text-xs font-medium rounded-full',
|
||||||
|
member.isMannschaftsspieler
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
|
||||||
|
<template v-if="canViewContactData">
|
||||||
|
<div
|
||||||
|
v-if="member.email"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<Mail
|
||||||
|
:size="16"
|
||||||
|
class="mr-2 text-primary-600"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
:href="`mailto:${member.email}`"
|
||||||
|
class="hover:text-primary-600"
|
||||||
|
>{{ member.email }}</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="member.phone"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<Phone
|
||||||
|
:size="16"
|
||||||
|
class="mr-2 text-primary-600"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
:href="`tel:${member.phone}`"
|
||||||
|
class="hover:text-primary-600"
|
||||||
|
>{{ member.phone }}</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="col-span-2 flex items-center text-gray-500 text-sm italic"
|
||||||
|
>
|
||||||
|
<Mail
|
||||||
|
:size="16"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
Kontaktdaten nur für Vorstand sichtbar
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="member.address"
|
||||||
|
class="flex items-start col-span-2"
|
||||||
|
>
|
||||||
|
<MapPin
|
||||||
|
:size="16"
|
||||||
|
class="mr-2 text-primary-600 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>{{ member.address }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="member.notes"
|
||||||
|
class="flex items-start col-span-2"
|
||||||
|
>
|
||||||
|
<FileText
|
||||||
|
:size="16"
|
||||||
|
class="mr-2 text-primary-600 mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>{{ member.notes }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="member.lastLogin"
|
||||||
|
class="flex items-center col-span-2 text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
<Clock
|
||||||
|
:size="16"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
Letzter Login: {{ formatDate(member.lastLogin) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="canEdit && member.editable"
|
||||||
|
class="flex space-x-2 ml-4"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="Bearbeiten"
|
||||||
|
@click="openEditModal(member)"
|
||||||
|
>
|
||||||
|
<Edit :size="20" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Löschen"
|
||||||
|
@click="confirmDelete(member)"
|
||||||
|
>
|
||||||
|
<Trash2 :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="members.length === 0"
|
||||||
|
class="text-center py-12 text-gray-500"
|
||||||
|
>
|
||||||
|
Keine Mitglieder gefunden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit/Add Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showModal"
|
||||||
|
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
|
@click.self="closeModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
|
||||||
|
{{ editingMember ? 'Mitglied bearbeiten' : 'Mitglied hinzufügen' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-4"
|
||||||
|
@submit.prevent="saveMember"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Vorname *</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Nachname *</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Geburtsdatum *</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.geburtsdatum"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Wird zur eindeutigen Identifizierung benötigt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.email"
|
||||||
|
type="email"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.phone"
|
||||||
|
type="tel"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Adresse</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.address"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Notizen</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.notes"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="isMannschaftsspieler"
|
||||||
|
v-model="formData.isMannschaftsspieler"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="isMannschaftsspieler"
|
||||||
|
class="ml-2 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Mannschaftsspieler
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||||
|
>
|
||||||
|
<AlertCircle
|
||||||
|
:size="20"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
||||||
|
:disabled="isSaving"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="isSaving"
|
||||||
|
:size="20"
|
||||||
|
class="animate-spin mr-2"
|
||||||
|
/>
|
||||||
|
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Import Modal -->
|
||||||
|
<div
|
||||||
|
v-if="showBulkImportModal"
|
||||||
|
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
|
@click.self="closeBulkImportModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto p-8">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
|
||||||
|
Bulk-Import von Mitgliedern
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- CSV Upload Section -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">CSV-Datei hochladen</label>
|
||||||
|
<div
|
||||||
|
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||||
|
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
||||||
|
@click="triggerBulkFileInput"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent="isDragOver = true"
|
||||||
|
@dragleave.prevent="isDragOver = false"
|
||||||
|
@drop.prevent="handleBulkFileDrop"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
CSV-Datei hochladen
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="bulkSelectedFile"
|
||||||
|
class="text-sm text-primary-600 font-medium"
|
||||||
|
>
|
||||||
|
{{ bulkSelectedFile.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="bulkFileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleBulkFileSelect"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV Format Info -->
|
||||||
|
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-lg mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-blue-800 mb-2">
|
||||||
|
Erwartetes CSV-Format:
|
||||||
|
</h4>
|
||||||
|
<div class="text-xs text-blue-700 space-y-1">
|
||||||
|
<p>• Erste Zeile: Spaltenüberschriften (firstName, lastName, geburtsdatum, email, phone, address, notes)</p>
|
||||||
|
<p>• <strong>Pflichtfelder:</strong> firstName, lastName, geburtsdatum</p>
|
||||||
|
<p>• <strong>Geburtsdatum:</strong> Format YYYY-MM-DD (z.B. 1990-01-15)</p>
|
||||||
|
<p>• Trennzeichen: Komma (,) oder Semikolon (;)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div
|
||||||
|
v-if="bulkPreviewData.length > 0"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Vorschau ({{ bulkPreviewData.length }} Einträge)
|
||||||
|
</h3>
|
||||||
|
<div class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Vorname</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="(row, index) in bulkPreviewData.slice(0, 10)"
|
||||||
|
:key="index"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-3 py-2">{{ row.firstName || '-' }}</td>
|
||||||
|
<td class="px-3 py-2">{{ row.lastName || '-' }}</td>
|
||||||
|
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td>
|
||||||
|
<td class="px-3 py-2">{{ row.email || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div
|
||||||
|
v-if="bulkPreviewData.length > 10"
|
||||||
|
class="px-3 py-2 text-xs text-gray-500 bg-gray-50 text-center"
|
||||||
|
>
|
||||||
|
... und {{ bulkPreviewData.length - 10 }} weitere
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Results -->
|
||||||
|
<div
|
||||||
|
v-if="bulkImportResults"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3>
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Importiert</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Duplikate</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div>
|
||||||
|
<div class="text-sm text-gray-600">Fehler</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4>
|
||||||
|
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
<div v-for="dup in bulkImportResults.results.duplicates" :key="dup.index">
|
||||||
|
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4>
|
||||||
|
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
|
||||||
|
<div v-for="err in bulkImportResults.results.errors" :key="err.index">
|
||||||
|
Zeile {{ err.index }}: {{ err.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
:disabled="isBulkImporting"
|
||||||
|
@click="closeBulkImportModal"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="!bulkPreviewData.length || isBulkImporting"
|
||||||
|
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center disabled:bg-gray-400"
|
||||||
|
@click="processBulkImport"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="isBulkImporting"
|
||||||
|
:size="20"
|
||||||
|
class="animate-spin mr-2"
|
||||||
|
/>
|
||||||
|
<span>{{ isBulkImporting ? 'Importiert...' : 'Importieren' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const members = ref([])
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingMember = ref(null)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const viewMode = ref('cards')
|
||||||
|
|
||||||
|
// Bulk import state
|
||||||
|
const showBulkImportModal = ref(false)
|
||||||
|
const bulkFileInput = ref(null)
|
||||||
|
const bulkSelectedFile = ref(null)
|
||||||
|
const bulkPreviewData = ref([])
|
||||||
|
const isBulkImporting = ref(false)
|
||||||
|
const bulkImportResults = ref(null)
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
geburtsdatum: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
notes: '',
|
||||||
|
isMannschaftsspieler: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
return authStore.hasAnyRole('admin', 'vorstand')
|
||||||
|
})
|
||||||
|
|
||||||
|
const canViewContactData = computed(() => {
|
||||||
|
return authStore.hasRole('vorstand')
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadMembers = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/members')
|
||||||
|
members.value = response.members
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Mitglieder:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
editingMember.value = null
|
||||||
|
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false }
|
||||||
|
showModal.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (member) => {
|
||||||
|
editingMember.value = member
|
||||||
|
formData.value = {
|
||||||
|
firstName: member.firstName || '',
|
||||||
|
lastName: member.lastName || '',
|
||||||
|
geburtsdatum: member.geburtsdatum || '',
|
||||||
|
email: member.email || '',
|
||||||
|
phone: member.phone || '',
|
||||||
|
address: member.address || '',
|
||||||
|
notes: member.notes || '',
|
||||||
|
isMannschaftsspieler: member.isMannschaftsspieler === true
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
showModal.value = false
|
||||||
|
editingMember.value = null
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMember = async () => {
|
||||||
|
isSaving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch('/api/members', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { id: editingMember.value?.id, ...formData.value }
|
||||||
|
})
|
||||||
|
closeModal()
|
||||||
|
await loadMembers()
|
||||||
|
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mitglied erfolgreich gespeichert.')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Speichern:', error)
|
||||||
|
const errorMsg = error.data?.message || error.message || 'Fehler beim Speichern des Mitglieds.'
|
||||||
|
errorMessage.value = errorMsg
|
||||||
|
if ((error.statusCode === 409 || error.status === 409) && window.showErrorModal) {
|
||||||
|
window.showErrorModal('Duplikat gefunden', errorMsg)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMannschaftsspieler = async (member) => {
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/members/toggle-mannschaftsspieler', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { memberId: member.id }
|
||||||
|
})
|
||||||
|
member.isMannschaftsspieler = response.isMannschaftsspieler
|
||||||
|
await loadMembers()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Umschalten des Mannschaftsspieler-Status:', error)
|
||||||
|
if (window.showErrorModal) window.showErrorModal('Fehler', error.data?.message || 'Fehler beim Umschalten des Status.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (member) => {
|
||||||
|
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/members', { method: 'DELETE', body: { id: member.id } })
|
||||||
|
await loadMembers()
|
||||||
|
window.showSuccessModal('Erfolg', 'Mitglied wurde erfolgreich gelöscht')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen:', error)
|
||||||
|
window.showErrorModal('Fehler', 'Fehler beim Löschen des Mitglieds')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk import functions
|
||||||
|
const triggerBulkFileInput = () => { bulkFileInput.value?.click() }
|
||||||
|
const handleBulkFileSelect = (event) => { const file = event.target.files?.[0]; if (file) processBulkCSV(file) }
|
||||||
|
const handleBulkFileDrop = (event) => { isDragOver.value = false; const file = event.dataTransfer?.files?.[0]; if (file && file.type === 'text/csv') processBulkCSV(file) }
|
||||||
|
|
||||||
|
const processBulkCSV = async (file) => {
|
||||||
|
bulkSelectedFile.value = file
|
||||||
|
bulkImportResults.value = null
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||||
|
if (lines.length < 2) { window.showErrorModal('Fehler', 'CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten'); return }
|
||||||
|
const parseCSVLine = (line) => {
|
||||||
|
const tabCount = (line.match(/\t/g) || []).length
|
||||||
|
const semicolonCount = (line.match(/;/g) || []).length
|
||||||
|
const delimiter = tabCount > semicolonCount ? '\t' : (semicolonCount > 0 ? ';' : ',')
|
||||||
|
return line.split(delimiter).map(value => value.trim().replace(/^"|"$/g, ''))
|
||||||
|
}
|
||||||
|
const headers = parseCSVLine(lines[0]).map(h => h.toLowerCase())
|
||||||
|
const firstNameIdx = headers.findIndex(h => h.includes('firstname') || h.includes('vorname'))
|
||||||
|
const lastNameIdx = headers.findIndex(h => h.includes('lastname') || h.includes('nachname'))
|
||||||
|
const geburtsdatumIdx = headers.findIndex(h => h.includes('geburtsdatum') || h.includes('birthdate') || h.includes('geburt'))
|
||||||
|
const emailIdx = headers.findIndex(h => h.includes('email') || h.includes('e-mail'))
|
||||||
|
const phoneIdx = headers.findIndex(h => h.includes('phone') || h.includes('telefon') || h.includes('tel'))
|
||||||
|
const addressIdx = headers.findIndex(h => h.includes('address') || h.includes('adresse'))
|
||||||
|
const notesIdx = headers.findIndex(h => h.includes('note') || h.includes('notiz') || h.includes('bemerkung'))
|
||||||
|
if (firstNameIdx === -1 || lastNameIdx === -1 || geburtsdatumIdx === -1) { window.showErrorModal('Fehler', 'CSV muss Spalten für firstName, lastName und geburtsdatum enthalten'); return }
|
||||||
|
bulkPreviewData.value = lines.slice(1).map((line) => {
|
||||||
|
const values = parseCSVLine(line)
|
||||||
|
return {
|
||||||
|
firstName: values[firstNameIdx] || '',
|
||||||
|
lastName: values[lastNameIdx] || '',
|
||||||
|
geburtsdatum: values[geburtsdatumIdx] || '',
|
||||||
|
email: emailIdx !== -1 ? (values[emailIdx] || '') : '',
|
||||||
|
phone: phoneIdx !== -1 ? (values[phoneIdx] || '') : '',
|
||||||
|
address: addressIdx !== -1 ? (values[addressIdx] || '') : '',
|
||||||
|
notes: notesIdx !== -1 ? (values[notesIdx] || '') : ''
|
||||||
|
}
|
||||||
|
}).filter(row => row.firstName && row.lastName && row.geburtsdatum)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Parsen der CSV:', error)
|
||||||
|
window.showErrorModal('Fehler', 'Fehler beim Lesen der CSV-Datei: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processBulkImport = async () => {
|
||||||
|
if (!bulkPreviewData.value.length) return
|
||||||
|
isBulkImporting.value = true
|
||||||
|
bulkImportResults.value = null
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/members/bulk', { method: 'POST', body: { members: bulkPreviewData.value } })
|
||||||
|
bulkImportResults.value = response
|
||||||
|
if (response.summary.imported > 0) {
|
||||||
|
await loadMembers()
|
||||||
|
window.showSuccessModal('Import erfolgreich', `${response.summary.imported} Mitglieder wurden erfolgreich importiert.`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Bulk-Import:', error)
|
||||||
|
window.showErrorModal('Import-Fehler', error.data?.message || error.message || 'Fehler beim Import')
|
||||||
|
} finally {
|
||||||
|
isBulkImporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBulkImportModal = () => {
|
||||||
|
showBulkImportModal.value = false
|
||||||
|
bulkSelectedFile.value = null
|
||||||
|
bulkPreviewData.value = []
|
||||||
|
bulkImportResults.value = null
|
||||||
|
isDragOver.value = false
|
||||||
|
if (bulkFileInput.value) bulkFileInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadMembers() })
|
||||||
|
</script>
|
||||||
307
components/cms/CmsMitgliedschaftsantraege.vue
Normal file
307
components/cms/CmsMitgliedschaftsantraege.vue
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900">
|
||||||
|
Mitgliedschaftsanträge
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
:disabled="loading"
|
||||||
|
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
|
||||||
|
@click="refreshApplications"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="text-center py-12"
|
||||||
|
>
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto" />
|
||||||
|
<p class="mt-4 text-gray-600">
|
||||||
|
Lade Anträge...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Applications List -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="application in applications"
|
||||||
|
:key="application.id"
|
||||||
|
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Application Header -->
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Eingereicht: {{ formatDate(application.timestamp) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full text-sm font-medium',
|
||||||
|
getStatusClass(application.status)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getStatusText(application.status) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
||||||
|
@click="viewApplication(application)"
|
||||||
|
>
|
||||||
|
Anzeigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="application.metadata.pdfGenerated"
|
||||||
|
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors flex items-center"
|
||||||
|
@click="downloadPDF(application.id)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-1"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="application.status === 'pending'"
|
||||||
|
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
|
||||||
|
@click="approveApplication(application.id)"
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="application.status === 'pending'"
|
||||||
|
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
||||||
|
@click="rejectApplication(application.id)"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Details -->
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
|
||||||
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
|
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
|
||||||
|
<p v-if="application.personalData.telefon_privat"><strong>Telefon:</strong> {{ application.personalData.telefon_privat }}</p>
|
||||||
|
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
|
||||||
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
|
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
||||||
|
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
||||||
|
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Application Detail Modal -->
|
||||||
|
<div
|
||||||
|
v-if="selectedApplication"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
@click.self="closeModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
|
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="text-gray-400 hover:text-gray-600"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
|
||||||
|
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
|
||||||
|
<p v-if="selectedApplication.personalData.telefon_privat"><strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}</p>
|
||||||
|
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
|
||||||
|
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
||||||
|
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
||||||
|
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeModal">Schließen</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedApplication.metadata.pdfGenerated"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
|
||||||
|
@click="downloadPDF(selectedApplication.id)"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
PDF herunterladen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedApplication.status === 'pending'"
|
||||||
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||||
|
@click="approveApplication(selectedApplication.id)"
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedApplication.status === 'pending'"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||||
|
@click="rejectApplication(selectedApplication.id)"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const applications = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedApplication = ref(null)
|
||||||
|
|
||||||
|
const hasApplications = computed(() => applications.value.length > 0)
|
||||||
|
const isReady = computed(() => !loading.value)
|
||||||
|
|
||||||
|
defineExpose({ hasApplications, isReady })
|
||||||
|
|
||||||
|
const loadApplications = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/membership/applications')
|
||||||
|
applications.value = response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Anträge:', error)
|
||||||
|
window.showErrorModal('Fehler', 'Fehler beim Laden der Anträge')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshApplications = () => { loadApplications() }
|
||||||
|
const viewApplication = (application) => { selectedApplication.value = application }
|
||||||
|
const closeModal = () => { selectedApplication.value = null }
|
||||||
|
|
||||||
|
const approveApplication = async (id) => {
|
||||||
|
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich genehmigen?', async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/membership/update-status', { method: 'PUT', body: { id, status: 'approved' } })
|
||||||
|
await loadApplications()
|
||||||
|
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich genehmigt')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Genehmigen:', error)
|
||||||
|
window.showErrorModal('Fehler', 'Fehler beim Genehmigen des Antrags')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectApplication = async (id) => {
|
||||||
|
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich ablehnen?', async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/membership/update-status', { method: 'PUT', body: { id, status: 'rejected' } })
|
||||||
|
await loadApplications()
|
||||||
|
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich abgelehnt')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Ablehnen:', error)
|
||||||
|
window.showErrorModal('Fehler', 'Fehler beim Ablehnen des Antrags')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadPDF = async (id) => {
|
||||||
|
try {
|
||||||
|
const filename = `beitrittserklärung_${id}.pdf`
|
||||||
|
const response = await fetch(`/uploads/${filename}`)
|
||||||
|
if (!response.ok) throw new Error('PDF nicht gefunden')
|
||||||
|
const blob = await response.blob()
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
window.showSuccessModal('Erfolg', 'PDF wurde erfolgreich heruntergeladen')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Herunterladen:', error)
|
||||||
|
window.showErrorModal('Fehler', 'Fehler beim Herunterladen des PDFs')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusClass = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'bg-yellow-100 text-yellow-800'
|
||||||
|
case 'approved': return 'bg-green-100 text-green-800'
|
||||||
|
case 'rejected': return 'bg-red-100 text-red-800'
|
||||||
|
default: return 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'Ausstehend'
|
||||||
|
case 'approved': return 'Genehmigt'
|
||||||
|
case 'rejected': return 'Abgelehnt'
|
||||||
|
default: return 'Unbekannt'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadApplications() })
|
||||||
|
</script>
|
||||||
278
components/cms/CmsSatzung.vue
Normal file
278
components/cms/CmsSatzung.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 1. PDF-Upload -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
PDF-Upload
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="space-y-4"
|
||||||
|
@submit.prevent="uploadPdf"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="pdf-file"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
Neue Satzung hochladen (PDF)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pdf-file"
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Nur PDF-Dateien bis 10MB sind erlaubt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!selectedFile || uploading"
|
||||||
|
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="uploading"
|
||||||
|
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ uploading ? 'Wird hochgeladen...' : 'PDF hochladen' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Aktuelle PDF-Information -->
|
||||||
|
<div
|
||||||
|
v-if="currentPdfUrl"
|
||||||
|
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
|
Aktuelle Satzung (PDF)
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
PDF-Datei verfügbar
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
:href="currentPdfUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
Satzung anzeigen →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Zuletzt aktualisiert: {{ lastUpdated }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Textfassung (WYSIWYG) -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
Textfassung für die Website
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
:disabled="savingText"
|
||||||
|
@click="saveText"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="savingText"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ savingText ? 'Speichert...' : 'Text speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">
|
||||||
|
Diese HTML-Fassung wird auf der Seite „Verein → Satzung" angezeigt. Die PDF-Version bleibt die rechtlich verbindliche Fassung.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RichTextEditor
|
||||||
|
v-model="satzungContent"
|
||||||
|
label="Satzung (HTML-Version)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="message"
|
||||||
|
class="mt-4 p-4 rounded-lg"
|
||||||
|
:class="messageType === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import RichTextEditor from '~/components/RichTextEditor.vue'
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const selectedFile = ref(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const currentPdfUrl = ref('')
|
||||||
|
const lastUpdated = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const messageType = ref('')
|
||||||
|
const satzungContent = ref('')
|
||||||
|
const savingText = ref(false)
|
||||||
|
|
||||||
|
async function loadCurrentSatzung() {
|
||||||
|
try {
|
||||||
|
const data = await $fetch('/api/config')
|
||||||
|
const satzung = data?.seiten?.satzung
|
||||||
|
if (satzung?.pdfUrl) {
|
||||||
|
currentPdfUrl.value = satzung.pdfUrl
|
||||||
|
lastUpdated.value = new Date().toLocaleDateString('de-DE')
|
||||||
|
}
|
||||||
|
if (satzung?.content) {
|
||||||
|
const content = typeof satzung.content === 'string' ? satzung.content : String(satzung.content || '')
|
||||||
|
satzungContent.value = content
|
||||||
|
} else {
|
||||||
|
satzungContent.value = ''
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der aktuellen Satzung:', e)
|
||||||
|
satzungContent.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
if (file.type !== 'application/pdf') {
|
||||||
|
message.value = 'Bitte wählen Sie eine PDF-Datei aus'
|
||||||
|
messageType.value = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.value = 'Die Datei ist zu groß (max. 10MB)'
|
||||||
|
messageType.value = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedFile.value = file
|
||||||
|
message.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadPdf() {
|
||||||
|
if (!selectedFile.value) return
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
message.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('pdf', selectedFile.value)
|
||||||
|
|
||||||
|
const result = await $fetch('/api/cms/satzung-upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
message.value = result.message
|
||||||
|
messageType.value = 'success'
|
||||||
|
|
||||||
|
await loadCurrentSatzung()
|
||||||
|
|
||||||
|
selectedFile.value = null
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
message.value = error.data?.message || 'Fehler beim Hochladen der PDF-Datei'
|
||||||
|
messageType.value = 'error'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveText() {
|
||||||
|
savingText.value = true
|
||||||
|
message.value = ''
|
||||||
|
try {
|
||||||
|
const current = await $fetch('/api/config')
|
||||||
|
const currentSeiten = current.seiten || {}
|
||||||
|
const currentSatzung = currentSeiten.satzung || {}
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
seiten: {
|
||||||
|
...currentSeiten,
|
||||||
|
satzung: {
|
||||||
|
...currentSatzung,
|
||||||
|
content: satzungContent.value || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch('/api/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: updated
|
||||||
|
})
|
||||||
|
|
||||||
|
message.value = 'Satzungstext erfolgreich gespeichert'
|
||||||
|
messageType.value = 'success'
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.showSuccessModal && window.showSuccessModal('Erfolg', 'Satzungstext erfolgreich gespeichert.')
|
||||||
|
} catch {
|
||||||
|
// Modal optional
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = error?.data?.message || 'Fehler beim Speichern des Satzungstextes'
|
||||||
|
message.value = errMsg
|
||||||
|
messageType.value = 'error'
|
||||||
|
try {
|
||||||
|
window.showErrorModal && window.showErrorModal('Fehler', errMsg)
|
||||||
|
} catch {
|
||||||
|
// optional
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
savingText.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadCurrentSatzung)
|
||||||
|
</script>
|
||||||
194
components/cms/CmsSpielplaene.vue
Normal file
194
components/cms/CmsSpielplaene.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2>
|
||||||
|
<div class="space-x-3">
|
||||||
|
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||||
|
CSV hochladen
|
||||||
|
</button>
|
||||||
|
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV Upload Section -->
|
||||||
|
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3>
|
||||||
|
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div>
|
||||||
|
</div>
|
||||||
|
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer" :class="{ 'border-primary-400 bg-primary-50': isDragOver }" @click="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop">
|
||||||
|
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||||
|
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p>
|
||||||
|
</div>
|
||||||
|
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column Selection -->
|
||||||
|
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded">
|
||||||
|
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-between items-center">
|
||||||
|
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div>
|
||||||
|
<div class="space-x-3">
|
||||||
|
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button>
|
||||||
|
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button>
|
||||||
|
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button>
|
||||||
|
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Preview -->
|
||||||
|
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button>
|
||||||
|
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
|
||||||
|
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div>
|
||||||
|
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
|
||||||
|
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||||
|
<p class="text-gray-600">Keine CSV-Daten geladen.</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal">
|
||||||
|
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div>
|
||||||
|
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div>
|
||||||
|
<div class="bg-blue-50 p-4 rounded-lg"><h4 class="text-sm font-medium text-blue-800 mb-2">Erwartetes CSV-Format:</h4><div class="text-xs text-blue-700 space-y-1"><p>• Erste Zeile: Spaltenüberschriften</p><p>• Trennzeichen: Komma (,)</p></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button>
|
||||||
|
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing Modal -->
|
||||||
|
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3>
|
||||||
|
<p class="text-sm text-gray-600">{{ processingMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const modalFileInput = ref(null)
|
||||||
|
const showUploadModal = ref(false)
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
const processingMessage = ref('')
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
const currentFile = ref(null)
|
||||||
|
const selectedFile = ref(null)
|
||||||
|
const csvData = ref([])
|
||||||
|
const csvHeaders = ref([])
|
||||||
|
const selectedColumns = ref([])
|
||||||
|
const columnsSelected = ref(false)
|
||||||
|
const filteredCsvData = ref([])
|
||||||
|
const filteredCsvHeaders = ref([])
|
||||||
|
|
||||||
|
const triggerFileInput = () => { fileInput.value?.click() }
|
||||||
|
const handleFileSelect = (event) => { const file = event.target.files[0]; if (file) processFile(file) }
|
||||||
|
const handleModalFileSelect = (event) => { selectedFile.value = event.target.files[0] }
|
||||||
|
const handleFileDrop = (event) => { isDragOver.value = false; const file = event.dataTransfer.files[0]; if (file && file.type === 'text/csv') processFile(file) }
|
||||||
|
|
||||||
|
const processFile = async (file) => {
|
||||||
|
isProcessing.value = true; processingMessage.value = 'Datei wird gelesen...'
|
||||||
|
try {
|
||||||
|
const text = await file.text(); processingMessage.value = 'CSV wird geparst...'
|
||||||
|
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||||
|
if (lines.length < 2) throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
|
||||||
|
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
|
||||||
|
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||||
|
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
|
||||||
|
currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified }
|
||||||
|
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
||||||
|
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
|
||||||
|
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
|
||||||
|
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
|
||||||
|
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
|
||||||
|
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
|
||||||
|
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
|
||||||
|
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
|
||||||
|
const confirmColumnSelection = () => { const si = selectedColumns.value.map((s, i) => s ? i : -1).filter(i => i !== -1); filteredCsvHeaders.value = si.map(i => csvHeaders.value[i]); filteredCsvData.value = csvData.value.map(row => si.map(i => row[i])); columnsSelected.value = true }
|
||||||
|
const suggestHalleColumns = () => { csvHeaders.value.forEach((header, index) => { const h = header.toLowerCase(); if (h.includes('halle') || h.includes('strasse') || h.includes('plz') || h.includes('ort')) selectedColumns.value[index] = true }) }
|
||||||
|
const clearData = () => { if (confirm('Möchten Sie alle Daten wirklich löschen?')) removeFile() }
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const d = columnsSelected.value ? filteredCsvData.value : csvData.value; const h = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
||||||
|
if (d.length === 0) return
|
||||||
|
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const d = columnsSelected.value ? filteredCsvData.value : csvData.value; const h = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
||||||
|
if (d.length === 0) { alert('Keine Daten zum Speichern vorhanden.'); return }
|
||||||
|
try {
|
||||||
|
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
|
||||||
|
const response = await fetch('/api/cms/save-csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'spielplan.csv', content: csv }) })
|
||||||
|
if (response.ok) alert('Spielplan erfolgreich gespeichert!'); else alert('Fehler beim Speichern!')
|
||||||
|
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' }
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/data/spielplan.csv'); if (!response.ok) return; const text = await response.text()
|
||||||
|
const lines = text.split('\n').filter(line => line.trim() !== ''); if (lines.length < 2) return
|
||||||
|
const parseCSVLine = (line) => { 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()); return values }
|
||||||
|
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||||
|
currentFile.value = { name: 'spielplan.csv', size: text.length, lastModified: null }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
188
components/cms/CmsTermine.vue
Normal file
188
components/cms/CmsTermine.vue
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
||||||
|
Termine verwalten
|
||||||
|
</h2>
|
||||||
|
<div class="w-24 h-1 bg-primary-600" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
@click="openAddModal"
|
||||||
|
>
|
||||||
|
<Plus :size="20" class="mr-2" />
|
||||||
|
Termin hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||||
|
<Loader2 :size="40" class="animate-spin text-primary-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Termine Table -->
|
||||||
|
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
'bg-blue-100 text-blue-800': termin.kategorie === 'Training',
|
||||||
|
'bg-green-100 text-green-800': termin.kategorie === 'Punktspiel',
|
||||||
|
'bg-purple-100 text-purple-800': termin.kategorie === 'Turnier',
|
||||||
|
'bg-yellow-100 text-yellow-800': termin.kategorie === 'Veranstaltung',
|
||||||
|
'bg-gray-100 text-gray-800': termin.kategorie === 'Sonstiges'
|
||||||
|
}"
|
||||||
|
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||||
|
>{{ termin.kategorie }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||||
|
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button>
|
||||||
|
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
||||||
|
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2>
|
||||||
|
<form class="space-y-4" @submit.prevent="saveTermin">
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
|
||||||
|
<input v-model="formData.datum" type="date" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
|
||||||
|
<input v-model="formData.uhrzeit" type="time" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
|
||||||
|
<select v-model="formData.kategorie" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
<option value="Training">Training</option>
|
||||||
|
<option value="Punktspiel">Punktspiel</option>
|
||||||
|
<option value="Turnier">Turnier</option>
|
||||||
|
<option value="Veranstaltung">Veranstaltung</option>
|
||||||
|
<option value="Sonstiges">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
|
||||||
|
<input v-model="formData.titel" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||||
|
<textarea v-model="formData.beschreibung" rows="3" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving" />
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
||||||
|
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-4 pt-4">
|
||||||
|
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving">
|
||||||
|
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" />
|
||||||
|
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Plus, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const termine = ref([])
|
||||||
|
const showModal = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const originalTermin = ref(null)
|
||||||
|
|
||||||
|
const formData = ref({ datum: '', titel: '', beschreibung: '', kategorie: 'Sonstiges', uhrzeit: '' })
|
||||||
|
|
||||||
|
const loadTermine = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await $fetch('/api/termine-manage')
|
||||||
|
termine.value = response.termine
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Termine:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
formData.value = { datum: '', titel: '', beschreibung: '', kategorie: 'Sonstiges', uhrzeit: '' }
|
||||||
|
showModal.value = true; errorMessage.value = ''; isEditing.value = false; originalTermin.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; originalTermin.value = null }
|
||||||
|
|
||||||
|
const saveTermin = async () => {
|
||||||
|
isSaving.value = true; errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
if (isEditing.value && originalTermin.value) {
|
||||||
|
const params = new URLSearchParams({ datum: originalTermin.value.datum, uhrzeit: originalTermin.value.uhrzeit || '', titel: originalTermin.value.titel, beschreibung: originalTermin.value.beschreibung || '', kategorie: originalTermin.value.kategorie || 'Sonstiges' })
|
||||||
|
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
await $fetch('/api/termine-manage', { method: 'POST', body: formData.value })
|
||||||
|
closeModal(); await loadTermine()
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Termins.'
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditModal = (termin) => {
|
||||||
|
formData.value = { datum: termin.datum || '', uhrzeit: termin.uhrzeit || '', titel: termin.titel || '', beschreibung: termin.beschreibung || '', kategorie: termin.kategorie || 'Sonstiges' }
|
||||||
|
originalTermin.value = { ...termin }; isEditing.value = true; showModal.value = true; errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async (termin) => {
|
||||||
|
window.showConfirmModal('Termin löschen', `Möchten Sie den Termin "${termin.titel}" wirklich löschen?`, async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ datum: termin.datum, uhrzeit: termin.uhrzeit || '', titel: termin.titel, beschreibung: termin.beschreibung || '', kategorie: termin.kategorie || 'Sonstiges' })
|
||||||
|
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
|
||||||
|
await loadTermine(); window.showSuccessModal('Erfolg', 'Termin wurde erfolgreich gelöscht')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen des Termins')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadTermine() })
|
||||||
|
</script>
|
||||||
183
components/cms/CmsTtRegeln.vue
Normal file
183
components/cms/CmsTtRegeln.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header with save button -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||||
|
TT-Regeln bearbeiten
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||||
|
<!-- Formatierung -->
|
||||||
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button>
|
||||||
|
</div>
|
||||||
|
<!-- Listen -->
|
||||||
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')">•</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button>
|
||||||
|
</div>
|
||||||
|
<!-- Schnellzugriff für Regeln -->
|
||||||
|
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button>
|
||||||
|
</div>
|
||||||
|
<!-- Weitere Tools -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button>
|
||||||
|
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hilfe-Sektion -->
|
||||||
|
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3>
|
||||||
|
<div class="text-sm text-blue-800 space-y-2">
|
||||||
|
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
|
||||||
|
<ul class="ml-4 space-y-1">
|
||||||
|
<li>• <span class="bg-gray-100 px-2 py-1 rounded text-xs">Neue Regel</span> - Graues Kästchen</li>
|
||||||
|
<li>• <span class="bg-blue-100 px-2 py-1 rounded text-xs">Grundregel</span> - Blaues Kästchen</li>
|
||||||
|
<li>• <span class="bg-green-100 px-2 py-1 rounded text-xs">Strafregel</span> - Grünes Kästchen</li>
|
||||||
|
<li>• <span class="bg-yellow-100 px-2 py-1 rounded text-xs">Aufschlag</span> - Gelbes Kästchen</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>2. Kästchen löschen:</strong> Klicken Sie in ein Kästchen und dann auf <span class="bg-red-100 px-2 py-1 rounded text-xs">Regel löschen</span></p>
|
||||||
|
<p><strong>3. Kästchen bearbeiten:</strong> Klicken Sie direkt in die Texte und bearbeiten Sie sie</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||||
|
<div
|
||||||
|
ref="editor"
|
||||||
|
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||||
|
contenteditable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const editor = ref(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await $fetch('/api/config')
|
||||||
|
const html = data?.seiten?.ttRegeln || ''
|
||||||
|
if (editor.value) editor.value.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const html = editor.value?.innerHTML || ''
|
||||||
|
const current = await $fetch('/api/config')
|
||||||
|
const updated = { ...current, seiten: { ...(current.seiten || {}), ttRegeln: html } }
|
||||||
|
try {
|
||||||
|
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||||
|
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch { /* */ }
|
||||||
|
} catch (error) {
|
||||||
|
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch { /* */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(cmd) { document.execCommand(cmd, false, null) }
|
||||||
|
function formatHeader(level) { document.execCommand('formatBlock', false, 'H' + level) }
|
||||||
|
function createLink() { const url = prompt('URL eingeben:'); if (url) document.execCommand('createLink', false, url) }
|
||||||
|
function removeFormat() { document.execCommand('removeFormat', false, null) }
|
||||||
|
|
||||||
|
function insertRuleTemplate(type) {
|
||||||
|
const editorElement = editor.value
|
||||||
|
if (!editorElement) return
|
||||||
|
|
||||||
|
const templates = {
|
||||||
|
generic: '<div class="text-center p-6 bg-gray-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Regel</h3><p class="text-gray-600 text-sm">[Regeltext hier eingeben]</p></div>',
|
||||||
|
basic: '<div class="text-center p-6 bg-blue-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Grundregel</h3><p class="text-gray-600 text-sm"><strong>Regel:</strong> [Regeltext hier eingeben]<br><strong>Beschreibung:</strong> [Detaillierte Beschreibung]<br><strong>Anwendung:</strong> [Wann gilt diese Regel?]</p></div>',
|
||||||
|
penalty: '<div class="text-center p-6 bg-green-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Strafregel</h3><p class="text-gray-600 text-sm"><strong>Verstoß:</strong> [Was ist der Verstoß?]<br><strong>Strafe:</strong> [Welche Strafe wird verhängt?]<br><strong>Häufigkeit:</strong> [Bei wiederholten Verstößen?]</p></div>',
|
||||||
|
service: '<div class="text-center p-6 bg-yellow-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Aufschlagregel</h3><p class="text-gray-600 text-sm"><strong>Regel:</strong> [Aufschlagregel hier eingeben]<br><strong>Technik:</strong> [Wie muss der Aufschlag ausgeführt werden?]<br><strong>Fehler:</strong> [Was gilt als Fehler?]</p></div>'
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = templates[type] || templates.generic
|
||||||
|
editorElement.focus()
|
||||||
|
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||||
|
let currentElement = range.commonAncestorContainer
|
||||||
|
if (currentElement.nodeType === Node.TEXT_NODE) currentElement = currentElement.parentElement
|
||||||
|
|
||||||
|
let targetContainer = currentElement
|
||||||
|
while (targetContainer && !targetContainer.classList.contains('grid')) {
|
||||||
|
targetContainer = targetContainer.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetContainer && targetContainer.classList.contains('md:grid-cols-2') && targetContainer.classList.contains('lg:grid-cols-3') && targetContainer.classList.contains('gap-6')) {
|
||||||
|
const tempDiv = document.createElement('div')
|
||||||
|
tempDiv.innerHTML = template
|
||||||
|
let newCard = null
|
||||||
|
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
||||||
|
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) { newCard = tempDiv.childNodes[i]; break }
|
||||||
|
}
|
||||||
|
if (newCard) {
|
||||||
|
targetContainer.appendChild(newCard)
|
||||||
|
const newRange = document.createRange()
|
||||||
|
const titleElement = newCard.querySelector('h3')
|
||||||
|
if (titleElement) { newRange.setStart(titleElement, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editorElement.innerHTML += template
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editorElement.innerHTML += template
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editorElement.innerHTML += template
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCurrentRule() {
|
||||||
|
const editorElement = editor.value
|
||||||
|
if (!editorElement) return
|
||||||
|
editorElement.focus()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||||
|
let currentElement = range.commonAncestorContainer
|
||||||
|
if (currentElement.nodeType === Node.TEXT_NODE) currentElement = currentElement.parentElement
|
||||||
|
let cardElement = currentElement
|
||||||
|
while (cardElement && !cardElement.classList.contains('text-center')) { cardElement = cardElement.parentElement }
|
||||||
|
if (cardElement && cardElement.classList.contains('text-center')) {
|
||||||
|
cardElement.remove()
|
||||||
|
const gridContainer = editorElement.querySelector('.grid')
|
||||||
|
if (gridContainer && gridContainer.children.length > 0) {
|
||||||
|
const firstCard = gridContainer.firstElementChild
|
||||||
|
const titleElement = firstCard.querySelector('h3')
|
||||||
|
if (titleElement) {
|
||||||
|
const newRange = document.createRange()
|
||||||
|
newRange.setStart(titleElement, 0); newRange.collapse(true)
|
||||||
|
selection.removeAllRanges(); selection.addRange(newRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
133
components/cms/CmsUeberUns.vue
Normal file
133
components/cms/CmsUeberUns.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header with save button -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||||
|
Über uns bearbeiten
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('bold')"
|
||||||
|
>
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('italic')"
|
||||||
|
>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(1)"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(2)"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="formatHeader(3)"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('insertUnorderedList')"
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="format('insertOrderedList')"
|
||||||
|
>
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="createLink()"
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||||
|
@click="removeFormat()"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="bg-white rounded-b-lg shadow-sm border border-t-0 border-gray-200 p-3 sm:p-4">
|
||||||
|
<div
|
||||||
|
ref="editor"
|
||||||
|
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||||
|
contenteditable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const editor = ref(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await $fetch('/api/config')
|
||||||
|
const html = data?.seiten?.ueberUns || ''
|
||||||
|
if (editor.value) editor.value.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const html = editor.value?.innerHTML || ''
|
||||||
|
const current = await $fetch('/api/config')
|
||||||
|
const updated = { ...current, seiten: { ...(current.seiten || {}), ueberUns: html } }
|
||||||
|
try {
|
||||||
|
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||||
|
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
|
||||||
|
// Modal nicht verfügbar
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
||||||
|
// Modal nicht verfügbar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(cmd) {
|
||||||
|
document.execCommand(cmd, false, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeader(level) {
|
||||||
|
document.execCommand('formatBlock', false, 'H' + level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLink() {
|
||||||
|
const url = prompt('URL eingeben:')
|
||||||
|
if (!url) return
|
||||||
|
document.execCommand('createLink', false, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFormat() {
|
||||||
|
document.execCommand('removeFormat', false, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
<div class="w-24 h-1 bg-primary-600 mb-8" />
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Über uns -->
|
<!-- Inhalte (gruppiert) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/ueber-uns"
|
to="/cms/inhalte"
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||||
>
|
>
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
@@ -20,83 +20,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
Über uns
|
Inhalte
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Seite „Über uns" bearbeiten (WYSIWYG)
|
Über uns, Geschichte, TT-Regeln & Satzung
|
||||||
</p>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Geschichte -->
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/geschichte"
|
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
|
||||||
>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center group-hover:bg-amber-600 transition-colors">
|
|
||||||
<Newspaper
|
|
||||||
:size="24"
|
|
||||||
class="text-amber-600 group-hover:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
|
||||||
Geschichte
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Vereinsgeschichte bearbeiten (WYSIWYG)
|
|
||||||
</p>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- TT-Regeln -->
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/tt-regeln"
|
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
|
||||||
>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center group-hover:bg-red-600 transition-colors">
|
|
||||||
<Newspaper
|
|
||||||
:size="24"
|
|
||||||
class="text-red-600 group-hover:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
|
||||||
TT-Regeln
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Tischtennis-Regeln bearbeiten (WYSIWYG)
|
|
||||||
</p>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Satzung -->
|
|
||||||
<NuxtLink
|
|
||||||
to="/cms/satzung"
|
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
|
||||||
>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-12 h-12 bg-slate-100 rounded-lg flex items-center justify-center group-hover:bg-slate-600 transition-colors">
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-slate-600 group-hover:text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
|
||||||
Satzung
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Satzung als PDF hochladen
|
|
||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- News -->
|
<!-- News -->
|
||||||
@@ -120,9 +48,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Termine -->
|
<!-- Sportbetrieb (gruppiert) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/termine"
|
to="/cms/sportbetrieb"
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||||
>
|
>
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
@@ -133,38 +61,17 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
Termine
|
Sportbetrieb
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Vereinstermine erstellen und verwalten
|
Termine, Mannschaften & Spielpläne
|
||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Mannschaften -->
|
<!-- Mitgliederverwaltung (gruppiert) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/cms/mannschaften"
|
to="/cms/mitgliederverwaltung"
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
|
||||||
>
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<div class="w-12 h-12 bg-teal-100 rounded-lg flex items-center justify-center group-hover:bg-teal-600 transition-colors">
|
|
||||||
<Users
|
|
||||||
:size="24"
|
|
||||||
class="text-teal-600 group-hover:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
|
||||||
Mannschaften
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Mannschaften bearbeiten und verwalten
|
|
||||||
</p>
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<!-- Mitglieder -->
|
|
||||||
<NuxtLink
|
|
||||||
to="/mitgliederbereich/mitglieder"
|
|
||||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||||
>
|
>
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
@@ -175,11 +82,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||||
Mitglieder
|
Mitgliederverwaltung
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-600">
|
<p class="text-gray-600">
|
||||||
Mitgliederliste bearbeiten
|
Mitgliederliste & Mitgliedschaftsanträge
|
||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
|||||||
61
pages/cms/inhalte.vue
Normal file
61
pages/cms/inhalte.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900">Inhalte verwalten</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Redaktionelle Inhalte der Website bearbeiten</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 mb-6">
|
||||||
|
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
:class="activeTab === tab.id
|
||||||
|
? 'border-primary-600 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div>
|
||||||
|
<CmsUeberUns v-if="activeTab === 'ueber-uns'" />
|
||||||
|
<CmsGeschichte v-if="activeTab === 'geschichte'" />
|
||||||
|
<CmsTtRegeln v-if="activeTab === 'tt-regeln'" />
|
||||||
|
<CmsSatzung v-if="activeTab === 'satzung'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import CmsUeberUns from '~/components/cms/CmsUeberUns.vue'
|
||||||
|
import CmsGeschichte from '~/components/cms/CmsGeschichte.vue'
|
||||||
|
import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue'
|
||||||
|
import CmsSatzung from '~/components/cms/CmsSatzung.vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Inhalte verwalten – CMS'
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref('ueber-uns')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'ueber-uns', label: 'Über uns' },
|
||||||
|
{ id: 'geschichte', label: 'Geschichte' },
|
||||||
|
{ id: 'tt-regeln', label: 'TT-Regeln' },
|
||||||
|
{ id: 'satzung', label: 'Satzung' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -1,836 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-full py-16 bg-gray-50">
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
|
|
||||||
Mannschaften verwalten
|
|
||||||
</h1>
|
|
||||||
<div class="w-24 h-1 bg-primary-600 mb-4" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
|
||||||
@click="openAddModal"
|
|
||||||
>
|
|
||||||
<Plus
|
|
||||||
:size="20"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
Mannschaft hinzufügen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="flex items-center justify-center py-12"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
:size="40"
|
|
||||||
class="animate-spin text-primary-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mannschaften Table -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Mannschaft
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Liga
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Staffelleiter
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Mannschaftsführer
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Spieler
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Aktionen
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="(mannschaft, index) in mannschaften"
|
|
||||||
:key="index"
|
|
||||||
class="hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
|
||||||
{{ mannschaft.mannschaft }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ mannschaft.liga }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ mannschaft.staffelleiter }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ mannschaft.mannschaftsfuehrer }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
<div class="max-w-xs truncate">
|
|
||||||
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
|
||||||
<button
|
|
||||||
class="text-gray-600 hover:text-gray-900"
|
|
||||||
title="Bearbeiten"
|
|
||||||
@click="openEditModal(mannschaft, index)"
|
|
||||||
>
|
|
||||||
<Pencil
|
|
||||||
:size="18"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="text-red-600 hover:text-red-900"
|
|
||||||
title="Löschen"
|
|
||||||
@click="confirmDelete(mannschaft, index)"
|
|
||||||
>
|
|
||||||
<Trash2
|
|
||||||
:size="18"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-if="!isLoading && mannschaften.length === 0"
|
|
||||||
class="bg-white rounded-xl shadow-lg p-12 text-center"
|
|
||||||
>
|
|
||||||
<Users
|
|
||||||
:size="48"
|
|
||||||
class="text-gray-400 mx-auto mb-4"
|
|
||||||
/>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Keine Mannschaften vorhanden
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
Fügen Sie die erste Mannschaft hinzu.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
|
||||||
@click="openAddModal"
|
|
||||||
>
|
|
||||||
Mannschaft hinzufügen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
|
||||||
<div
|
|
||||||
v-if="showModal"
|
|
||||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
|
||||||
@click.self="closeModal"
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="p-6 border-b border-gray-200">
|
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900">
|
|
||||||
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="p-6 space-y-4"
|
|
||||||
@submit.prevent="saveMannschaft"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Mannschaft *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.mannschaft"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Liga *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.liga"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Staffelleiter
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.staffelleiter"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Telefon
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.telefon"
|
|
||||||
type="tel"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Heimspieltag
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.heimspieltag"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Spielsystem
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.spielsystem"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Mannschaftsführer
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.mannschaftsfuehrer"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Spieler
|
|
||||||
</label>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-if="formData.spielerListe.length === 0"
|
|
||||||
class="text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
Noch keine Spieler eingetragen.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(spieler, index) in formData.spielerListe"
|
|
||||||
:key="spieler.id"
|
|
||||||
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="spieler.name"
|
|
||||||
type="text"
|
|
||||||
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
placeholder="Spielername"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
|
|
||||||
<!-- Reihenfolge -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Nach oben"
|
|
||||||
:disabled="isSaving || index === 0"
|
|
||||||
@click="moveSpielerUp(index)"
|
|
||||||
>
|
|
||||||
<ChevronUp :size="18" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Nach unten"
|
|
||||||
:disabled="isSaving || index === formData.spielerListe.length - 1"
|
|
||||||
@click="moveSpielerDown(index)"
|
|
||||||
>
|
|
||||||
<ChevronDown :size="18" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title="Spieler entfernen"
|
|
||||||
:disabled="isSaving"
|
|
||||||
@click="removeSpieler(spieler.id)"
|
|
||||||
>
|
|
||||||
<Trash2 :size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verschieben (kompakt in gleicher Zeile) -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
v-model="moveTargetBySpielerId[spieler.id]"
|
|
||||||
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
|
|
||||||
title="Mannschaft auswählen"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="t in mannschaftenSelectOptions"
|
|
||||||
:key="t"
|
|
||||||
:value="t"
|
|
||||||
>
|
|
||||||
{{ t }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
|
|
||||||
@click="moveSpielerToMannschaft(spieler.id)"
|
|
||||||
title="In ausgewählte Mannschaft verschieben"
|
|
||||||
>
|
|
||||||
<ArrowRight :size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
|
|
||||||
:disabled="isSaving"
|
|
||||||
@click="addSpieler()"
|
|
||||||
>
|
|
||||||
<Plus
|
|
||||||
:size="18"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
Spieler hinzufügen
|
|
||||||
</button>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Reihenfolge per ↑/↓ ändern. Verschieben nur bei bestehenden Mannschaften.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Weitere Informationen (Link)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.weitere_informationen_link"
|
|
||||||
type="url"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
placeholder="https://..."
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="errorMessage"
|
|
||||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
|
||||||
>
|
|
||||||
<AlertCircle
|
|
||||||
:size="20"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ errorMessage }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-4 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
:disabled="isSaving"
|
|
||||||
@click="closeModal"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
v-if="isSaving"
|
|
||||||
:size="20"
|
|
||||||
class="animate-spin mr-2"
|
|
||||||
/>
|
|
||||||
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
const mannschaften = ref([])
|
|
||||||
const showModal = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const isEditing = ref(false)
|
|
||||||
const editingIndex = ref(-1)
|
|
||||||
|
|
||||||
const formData = ref({
|
|
||||||
mannschaft: '',
|
|
||||||
liga: '',
|
|
||||||
staffelleiter: '',
|
|
||||||
telefon: '',
|
|
||||||
heimspieltag: '',
|
|
||||||
spielsystem: '',
|
|
||||||
mannschaftsfuehrer: '',
|
|
||||||
spielerListe: [],
|
|
||||||
weitere_informationen_link: '',
|
|
||||||
letzte_aktualisierung: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Für Verschieben-UI (Combobox pro Spieler)
|
|
||||||
const moveTargetBySpielerId = ref({})
|
|
||||||
// Pending-Änderungen für andere Teams (wird erst beim Speichern angewendet)
|
|
||||||
const pendingSpielerNamesByTeamIndex = ref({}) // { [index: number]: string[] }
|
|
||||||
|
|
||||||
function nowIsoDate() {
|
|
||||||
return new Date().toISOString().split('T')[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
function newSpielerItem(name = '') {
|
|
||||||
return {
|
|
||||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSpielerString(spielerString) {
|
|
||||||
if (!spielerString) return []
|
|
||||||
return String(spielerString)
|
|
||||||
.split(';')
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(name => newSpielerItem(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeSpielerList(spielerListe) {
|
|
||||||
return (spielerListe || [])
|
|
||||||
.map(s => (s?.name || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('; ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeSpielerNames(spielerNames) {
|
|
||||||
return (spielerNames || [])
|
|
||||||
.map(s => String(s || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('; ')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCsvText(url) {
|
|
||||||
const attempt = async () => {
|
|
||||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
|
||||||
const response = await fetch(withBuster, { cache: 'no-store' })
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
}
|
|
||||||
return await response.text()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await attempt()
|
|
||||||
} catch (e) {
|
|
||||||
// 1 Retry: hilft bei Firefox NS_ERROR_NET_PARTIAL_TRANSFER direkt nach Speichern
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150))
|
|
||||||
return await attempt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mannschaftenSelectOptions = computed(() => {
|
|
||||||
const current = (formData.value.mannschaft || '').trim()
|
|
||||||
const names = mannschaften.value
|
|
||||||
.map(m => (m?.mannschaft || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
return [...new Set([current, ...names])].filter(Boolean)
|
|
||||||
})
|
|
||||||
|
|
||||||
function resetSpielerDraftState() {
|
|
||||||
moveTargetBySpielerId.value = {}
|
|
||||||
pendingSpielerNamesByTeamIndex.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
|
||||||
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) {
|
|
||||||
return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
|
||||||
}
|
|
||||||
const existing = mannschaften.value[teamIndex]
|
|
||||||
const list = existing ? getSpielerListe(existing) : []
|
|
||||||
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]
|
|
||||||
return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMannschaften = async () => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const csv = await fetchCsvText('/api/mannschaften')
|
|
||||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
|
||||||
|
|
||||||
if (lines.length < 2) {
|
|
||||||
mannschaften.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mannschaften.value = lines.slice(1).map(line => {
|
|
||||||
// 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() || '',
|
|
||||||
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]?.trim() || ''
|
|
||||||
}
|
|
||||||
}).filter(mannschaft => mannschaft !== null && mannschaft.mannschaft !== '')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Laden der Mannschaften:', error)
|
|
||||||
errorMessage.value = 'Fehler beim Laden der Mannschaften'
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSpielerListe = (mannschaft) => {
|
|
||||||
if (!mannschaft.spieler) return []
|
|
||||||
return mannschaft.spieler.split(';').map(s => s.trim()).filter(s => s !== '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAddModal = () => {
|
|
||||||
formData.value = {
|
|
||||||
mannschaft: '',
|
|
||||||
liga: '',
|
|
||||||
staffelleiter: '',
|
|
||||||
telefon: '',
|
|
||||||
heimspieltag: '',
|
|
||||||
spielsystem: '',
|
|
||||||
mannschaftsfuehrer: '',
|
|
||||||
spielerListe: [],
|
|
||||||
weitere_informationen_link: '',
|
|
||||||
letzte_aktualisierung: nowIsoDate()
|
|
||||||
}
|
|
||||||
showModal.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
isEditing.value = false
|
|
||||||
editingIndex.value = -1
|
|
||||||
resetSpielerDraftState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
showModal.value = false
|
|
||||||
errorMessage.value = ''
|
|
||||||
isEditing.value = false
|
|
||||||
editingIndex.value = -1
|
|
||||||
resetSpielerDraftState()
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditModal = (mannschaft, index) => {
|
|
||||||
formData.value = {
|
|
||||||
mannschaft: mannschaft.mannschaft || '',
|
|
||||||
liga: mannschaft.liga || '',
|
|
||||||
staffelleiter: mannschaft.staffelleiter || '',
|
|
||||||
telefon: mannschaft.telefon || '',
|
|
||||||
heimspieltag: mannschaft.heimspieltag || '',
|
|
||||||
spielsystem: mannschaft.spielsystem || '',
|
|
||||||
mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '',
|
|
||||||
spielerListe: parseSpielerString(mannschaft.spieler || ''),
|
|
||||||
weitere_informationen_link: mannschaft.weitere_informationen_link || '',
|
|
||||||
letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate()
|
|
||||||
}
|
|
||||||
isEditing.value = true
|
|
||||||
editingIndex.value = index
|
|
||||||
showModal.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
resetSpielerDraftState()
|
|
||||||
// Pro Spieler: aktuelle Mannschaft vorauswählen
|
|
||||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
|
||||||
for (const s of formData.value.spielerListe) {
|
|
||||||
moveTargetBySpielerId.value[s.id] = currentTeam
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSpieler = () => {
|
|
||||||
const item = newSpielerItem('')
|
|
||||||
formData.value.spielerListe.push(item)
|
|
||||||
moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSpieler = (spielerId) => {
|
|
||||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId)
|
|
||||||
if (idx === -1) return
|
|
||||||
formData.value.spielerListe.splice(idx, 1)
|
|
||||||
if (moveTargetBySpielerId.value[spielerId]) {
|
|
||||||
delete moveTargetBySpielerId.value[spielerId]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveSpielerUp = (index) => {
|
|
||||||
if (index <= 0) return
|
|
||||||
const arr = formData.value.spielerListe
|
|
||||||
const item = arr[index]
|
|
||||||
arr.splice(index, 1)
|
|
||||||
arr.splice(index - 1, 0, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveSpielerDown = (index) => {
|
|
||||||
const arr = formData.value.spielerListe
|
|
||||||
if (index < 0 || index >= arr.length - 1) return
|
|
||||||
const item = arr[index]
|
|
||||||
arr.splice(index, 1)
|
|
||||||
arr.splice(index + 1, 0, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canMoveSpieler = (spielerId) => {
|
|
||||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim()
|
|
||||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
|
||||||
return Boolean(targetName) && Boolean(currentTeam) && targetName !== currentTeam
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveSpielerToMannschaft = (spielerId) => {
|
|
||||||
if (!isEditing.value || editingIndex.value < 0) return
|
|
||||||
|
|
||||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim()
|
|
||||||
if (!targetName) return
|
|
||||||
|
|
||||||
const targetIndex = mannschaften.value.findIndex((m, idx) => {
|
|
||||||
if (idx === editingIndex.value) return false
|
|
||||||
return (m?.mannschaft || '').trim() === targetName
|
|
||||||
})
|
|
||||||
|
|
||||||
if (targetIndex === -1) {
|
|
||||||
errorMessage.value = 'Ziel-Mannschaft nicht gefunden. Bitte aus der Liste auswählen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId)
|
|
||||||
if (idx === -1) return
|
|
||||||
|
|
||||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim()
|
|
||||||
if (!spielerName) {
|
|
||||||
errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entfernen aus aktueller Mannschaft
|
|
||||||
formData.value.spielerListe.splice(idx, 1)
|
|
||||||
|
|
||||||
// Hinzufügen zur Ziel-Mannschaft (pending; wird erst beim Speichern geschrieben)
|
|
||||||
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex)
|
|
||||||
pendingList.push(spielerName)
|
|
||||||
|
|
||||||
// UI zurücksetzen
|
|
||||||
delete moveTargetBySpielerId.value[spielerId]
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveMannschaft = async () => {
|
|
||||||
isSaving.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
|
||||||
const updated = {
|
|
||||||
mannschaft: formData.value.mannschaft || '',
|
|
||||||
liga: formData.value.liga || '',
|
|
||||||
staffelleiter: formData.value.staffelleiter || '',
|
|
||||||
telefon: formData.value.telefon || '',
|
|
||||||
heimspieltag: formData.value.heimspieltag || '',
|
|
||||||
spielsystem: formData.value.spielsystem || '',
|
|
||||||
mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '',
|
|
||||||
spieler: spielerString,
|
|
||||||
weitere_informationen_link: formData.value.weitere_informationen_link || '',
|
|
||||||
letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing.value && editingIndex.value >= 0) {
|
|
||||||
// Aktualisiere bestehende Mannschaft
|
|
||||||
mannschaften.value[editingIndex.value] = { ...updated }
|
|
||||||
} else {
|
|
||||||
// Füge neue Mannschaft hinzu
|
|
||||||
mannschaften.value.push({ ...updated })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending-Verschiebungen anwenden (andere Mannschaften)
|
|
||||||
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
|
|
||||||
if (touchedTeamIndexes.length > 0) {
|
|
||||||
const ts = nowIsoDate()
|
|
||||||
for (const idxStr of touchedTeamIndexes) {
|
|
||||||
const idx = Number(idxStr)
|
|
||||||
if (!Number.isFinite(idx)) continue
|
|
||||||
const existing = mannschaften.value[idx]
|
|
||||||
if (!existing) continue
|
|
||||||
const pendingNames = pendingSpielerNamesByTeamIndex.value[idx]
|
|
||||||
mannschaften.value[idx] = {
|
|
||||||
...existing,
|
|
||||||
spieler: serializeSpielerNames(pendingNames),
|
|
||||||
letzte_aktualisierung: ts
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speichere als CSV
|
|
||||||
await saveCSV()
|
|
||||||
|
|
||||||
closeModal()
|
|
||||||
await loadMannschaften()
|
|
||||||
|
|
||||||
if (window.showSuccessModal) {
|
|
||||||
window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Speichern:', error)
|
|
||||||
errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern der Mannschaft.'
|
|
||||||
if (window.showErrorModal) {
|
|
||||||
window.showErrorModal('Fehler', errorMessage.value)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCSV = async () => {
|
|
||||||
// CSV-Header
|
|
||||||
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
|
|
||||||
|
|
||||||
// CSV-Zeilen generieren
|
|
||||||
const rows = mannschaften.value.map(m => {
|
|
||||||
// Escape-Werte, die Kommas oder Anführungszeichen enthalten
|
|
||||||
const escapeCSV = (value) => {
|
|
||||||
if (!value) return ''
|
|
||||||
const str = String(value)
|
|
||||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
||||||
return `"${str.replace(/"/g, '""')}"`
|
|
||||||
}
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
escapeCSV(m.mannschaft),
|
|
||||||
escapeCSV(m.liga),
|
|
||||||
escapeCSV(m.staffelleiter),
|
|
||||||
escapeCSV(m.telefon),
|
|
||||||
escapeCSV(m.heimspieltag),
|
|
||||||
escapeCSV(m.spielsystem),
|
|
||||||
escapeCSV(m.mannschaftsfuehrer),
|
|
||||||
escapeCSV(m.spieler),
|
|
||||||
escapeCSV(m.weitere_informationen_link),
|
|
||||||
escapeCSV(m.letzte_aktualisierung)
|
|
||||||
].join(',')
|
|
||||||
})
|
|
||||||
|
|
||||||
const csvContent = [header, ...rows].join('\n')
|
|
||||||
|
|
||||||
// Speichere über API
|
|
||||||
await $fetch('/api/cms/save-csv', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
filename: 'mannschaften.csv',
|
|
||||||
content: csvContent
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = (mannschaft, index) => {
|
|
||||||
if (window.showConfirmModal) {
|
|
||||||
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
|
|
||||||
try {
|
|
||||||
mannschaften.value.splice(index, 1)
|
|
||||||
await saveCSV()
|
|
||||||
await loadMannschaften()
|
|
||||||
window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Löschen:', error)
|
|
||||||
window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Fallback ohne Modal
|
|
||||||
if (confirm(`Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) {
|
|
||||||
mannschaften.value.splice(index, 1)
|
|
||||||
saveCSV()
|
|
||||||
loadMannschaften()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadMannschaften().catch(() => {})
|
|
||||||
})
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Mannschaften verwalten - Harheimer TC',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
36
pages/cms/mitgliederverwaltung.vue
Normal file
36
pages/cms/mitgliederverwaltung.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900">Mitgliederverwaltung</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Anträge und Mitgliederliste verwalten</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) -->
|
||||||
|
<div v-show="antraegeRef?.hasApplications" class="mb-10">
|
||||||
|
<CmsMitgliedschaftsantraege ref="antraegeRef" />
|
||||||
|
</div>
|
||||||
|
<div v-if="antraegeRef?.hasApplications" class="border-t border-gray-300 mb-10" />
|
||||||
|
|
||||||
|
<!-- Mitgliederliste darunter -->
|
||||||
|
<CmsMitglieder />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import CmsMitglieder from '~/components/cms/CmsMitglieder.vue'
|
||||||
|
import CmsMitgliedschaftsantraege from '~/components/cms/CmsMitgliedschaftsantraege.vue'
|
||||||
|
|
||||||
|
const antraegeRef = ref(null)
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Mitgliederverwaltung – CMS'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<!-- Fixed Header -->
|
|
||||||
<div class="fixed top-16 left-0 right-0 bg-white shadow-sm border-b border-gray-200 z-40">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between py-3 sm:py-4">
|
|
||||||
<h1 class="text-xl sm:text-3xl font-bold text-gray-900">
|
|
||||||
Mitgliedschaftsanträge
|
|
||||||
</h1>
|
|
||||||
<button
|
|
||||||
:disabled="loading"
|
|
||||||
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
|
|
||||||
@click="refreshApplications"
|
|
||||||
>
|
|
||||||
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="pt-20 sm:pt-24">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="text-center py-12"
|
|
||||||
>
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto" />
|
|
||||||
<p class="mt-4 text-gray-600">
|
|
||||||
Lade Anträge...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-else-if="applications.length === 0"
|
|
||||||
class="text-center py-12"
|
|
||||||
>
|
|
||||||
<div class="text-gray-400 text-6xl mb-4">
|
|
||||||
📋
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Keine Anträge vorhanden
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Es wurden noch keine Mitgliedschaftsanträge eingereicht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Applications List -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="space-y-6"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="application in applications"
|
|
||||||
:key="application.id"
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Application Header -->
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">
|
|
||||||
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
Eingereicht: {{ formatDate(application.timestamp) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<!-- Status Badge -->
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'px-3 py-1 rounded-full text-sm font-medium',
|
|
||||||
getStatusClass(application.status)
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ getStatusText(application.status) }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
|
||||||
@click="viewApplication(application)"
|
|
||||||
>
|
|
||||||
Anzeigen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="application.metadata.pdfGenerated"
|
|
||||||
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors flex items-center"
|
|
||||||
@click="downloadPDF(application.id)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
PDF
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="application.status === 'pending'"
|
|
||||||
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
|
|
||||||
@click="approveApplication(application.id)"
|
|
||||||
>
|
|
||||||
Genehmigen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="application.status === 'pending'"
|
|
||||||
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
|
||||||
@click="rejectApplication(application.id)"
|
|
||||||
>
|
|
||||||
Ablehnen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Application Details -->
|
|
||||||
<div class="px-6 py-4">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-2">
|
|
||||||
Kontaktdaten
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-1 text-sm text-gray-600">
|
|
||||||
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
|
|
||||||
<p v-if="application.personalData.telefon_privat">
|
|
||||||
<strong>Telefon:</strong> {{ application.personalData.telefon_privat }}
|
|
||||||
</p>
|
|
||||||
<p v-if="application.personalData.telefon_mobil">
|
|
||||||
<strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-2">
|
|
||||||
Antragsdetails
|
|
||||||
</h4>
|
|
||||||
<div class="space-y-1 text-sm text-gray-600">
|
|
||||||
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
|
||||||
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
|
||||||
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Application Detail Modal -->
|
|
||||||
<div
|
|
||||||
v-if="selectedApplication"
|
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
|
||||||
@click.self="closeModal"
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900">
|
|
||||||
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
class="text-gray-400 hover:text-gray-600"
|
|
||||||
@click="closeModal"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 py-4">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- Personal Data -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
|
||||||
Persönliche Daten
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
|
|
||||||
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
|
|
||||||
<p v-if="selectedApplication.personalData.telefon_privat">
|
|
||||||
<strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}
|
|
||||||
</p>
|
|
||||||
<p v-if="selectedApplication.personalData.telefon_mobil">
|
|
||||||
<strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Application Details -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
|
||||||
Antragsdetails
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
|
|
||||||
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
|
|
||||||
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
|
|
||||||
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
|
||||||
<div class="flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
@click="closeModal"
|
|
||||||
>
|
|
||||||
Schließen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="selectedApplication.metadata.pdfGenerated"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
|
|
||||||
@click="downloadPDF(selectedApplication.id)"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
PDF herunterladen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="selectedApplication.status === 'pending'"
|
|
||||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
|
||||||
@click="approveApplication(selectedApplication.id)"
|
|
||||||
>
|
|
||||||
Genehmigen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="selectedApplication.status === 'pending'"
|
|
||||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
|
||||||
@click="rejectApplication(selectedApplication.id)"
|
|
||||||
>
|
|
||||||
Ablehnen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const applications = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const selectedApplication = ref(null)
|
|
||||||
|
|
||||||
const loadApplications = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await $fetch('/api/membership/applications')
|
|
||||||
applications.value = response
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Laden der Anträge:', error)
|
|
||||||
window.showErrorModal('Fehler', 'Fehler beim Laden der Anträge')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshApplications = () => {
|
|
||||||
loadApplications()
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewApplication = (application) => {
|
|
||||||
selectedApplication.value = application
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
selectedApplication.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const approveApplication = async (id) => {
|
|
||||||
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich genehmigen?', async () => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/membership/update-status', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: { id, status: 'approved' }
|
|
||||||
})
|
|
||||||
await loadApplications()
|
|
||||||
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich genehmigt')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Genehmigen:', error)
|
|
||||||
window.showErrorModal('Fehler', 'Fehler beim Genehmigen des Antrags')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const rejectApplication = async (id) => {
|
|
||||||
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich ablehnen?', async () => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/membership/update-status', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: { id, status: 'rejected' }
|
|
||||||
})
|
|
||||||
await loadApplications()
|
|
||||||
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich abgelehnt')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Ablehnen:', error)
|
|
||||||
window.showErrorModal('Fehler', 'Fehler beim Ablehnen des Antrags')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloadPDF = async (id) => {
|
|
||||||
try {
|
|
||||||
const filename = `beitrittserklärung_${id}.pdf`
|
|
||||||
|
|
||||||
// Direkter Download über die öffentliche Uploads-Route
|
|
||||||
const response = await fetch(`/uploads/${filename}`)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('PDF nicht gefunden')
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob()
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
document.body.removeChild(a)
|
|
||||||
|
|
||||||
window.showSuccessModal('Erfolg', 'PDF wurde erfolgreich heruntergeladen')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Herunterladen:', error)
|
|
||||||
window.showErrorModal('Fehler', 'Fehler beim Herunterladen des PDFs')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusClass = (status) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'pending':
|
|
||||||
return 'bg-yellow-100 text-yellow-800'
|
|
||||||
case 'approved':
|
|
||||||
return 'bg-green-100 text-green-800'
|
|
||||||
case 'rejected':
|
|
||||||
return 'bg-red-100 text-red-800'
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusText = (status) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'pending':
|
|
||||||
return 'Ausstehend'
|
|
||||||
case 'approved':
|
|
||||||
return 'Genehmigt'
|
|
||||||
case 'rejected':
|
|
||||||
return 'Abgelehnt'
|
|
||||||
default:
|
|
||||||
return 'Unbekannt'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadApplications()
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Mitgliedschaftsanträge - CMS - Harheimer TC',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-full py-16 bg-gray-50">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h1 class="text-3xl sm:text-4xl font-display font-bold text-gray-900">
|
|
||||||
Satzung verwalten
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">
|
|
||||||
PDF-Upload
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
class="space-y-4"
|
|
||||||
@submit.prevent="uploadPdf"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="pdf-file"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
Neue Satzung hochladen (PDF)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="pdf-file"
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".pdf"
|
|
||||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
|
||||||
Nur PDF-Dateien bis 10MB sind erlaubt
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="!selectedFile || uploading"
|
|
||||||
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
v-if="uploading"
|
|
||||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{{ uploading ? 'Wird hochgeladen...' : 'PDF hochladen' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="currentPdfUrl"
|
|
||||||
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
|
|
||||||
>
|
|
||||||
<h2 class="text-xl font-semibold mb-4">
|
|
||||||
Aktuelle Satzung
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
PDF-Datei verfügbar
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
:href="currentPdfUrl"
|
|
||||||
target="_blank"
|
|
||||||
class="text-primary-600 hover:text-primary-700 font-medium"
|
|
||||||
>
|
|
||||||
Satzung anzeigen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
Zuletzt aktualisiert: {{ lastUpdated }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="message"
|
|
||||||
class="mt-4 p-4 rounded-lg"
|
|
||||||
:class="messageType === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'"
|
|
||||||
>
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ title: 'CMS: Satzung' })
|
|
||||||
|
|
||||||
const fileInput = ref(null)
|
|
||||||
const selectedFile = ref(null)
|
|
||||||
const uploading = ref(false)
|
|
||||||
const currentPdfUrl = ref('')
|
|
||||||
const lastUpdated = ref('')
|
|
||||||
const message = ref('')
|
|
||||||
const messageType = ref('')
|
|
||||||
|
|
||||||
async function loadCurrentSatzung() {
|
|
||||||
try {
|
|
||||||
const data = await $fetch('/api/config')
|
|
||||||
const satzung = data?.seiten?.satzung
|
|
||||||
if (satzung?.pdfUrl) {
|
|
||||||
currentPdfUrl.value = satzung.pdfUrl
|
|
||||||
// Einfache Zeitstempel-Simulation
|
|
||||||
lastUpdated.value = new Date().toLocaleDateString('de-DE')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fehler beim Laden der aktuellen Satzung:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(event) {
|
|
||||||
const file = event.target.files[0]
|
|
||||||
if (file) {
|
|
||||||
if (file.type !== 'application/pdf') {
|
|
||||||
message.value = 'Bitte wählen Sie eine PDF-Datei aus'
|
|
||||||
messageType.value = 'error'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
message.value = 'Die Datei ist zu groß (max. 10MB)'
|
|
||||||
messageType.value = 'error'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedFile.value = file
|
|
||||||
message.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadPdf() {
|
|
||||||
if (!selectedFile.value) return
|
|
||||||
|
|
||||||
uploading.value = true
|
|
||||||
message.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('pdf', selectedFile.value)
|
|
||||||
|
|
||||||
const result = await $fetch('/api/cms/satzung-upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
|
|
||||||
message.value = result.message
|
|
||||||
messageType.value = 'success'
|
|
||||||
|
|
||||||
// Aktuelle Satzung neu laden
|
|
||||||
await loadCurrentSatzung()
|
|
||||||
|
|
||||||
// Formular zurücksetzen
|
|
||||||
selectedFile.value = null
|
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
message.value = error.data?.message || 'Fehler beim Hochladen der PDF-Datei'
|
|
||||||
messageType.value = 'error'
|
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadCurrentSatzung)
|
|
||||||
</script>
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-full bg-gray-50">
|
|
||||||
<!-- Fixed Header below navigation -->
|
|
||||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
|
||||||
Spielpläne bearbeiten
|
|
||||||
</h1>
|
|
||||||
<div class="space-x-3">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
|
|
||||||
@click="showUploadModal = true"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
CSV hochladen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
|
||||||
@click="save"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content with top padding -->
|
|
||||||
<div class="pt-20 pb-16">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<!-- CSV Upload Section -->
|
|
||||||
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
|
||||||
Vereins-Spielplan (CSV)
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- Current File Info -->
|
|
||||||
<div
|
|
||||||
v-if="currentFile"
|
|
||||||
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg
|
|
||||||
class="w-5 h-5 text-green-600 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-green-800">
|
|
||||||
{{ currentFile.name }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-green-600">
|
|
||||||
{{ currentFile.size }} bytes, {{ currentFile.lastModified ? new Date(currentFile.lastModified).toLocaleDateString('de-DE') : 'Unbekannt' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
|
||||||
@click="removeFile"
|
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Area -->
|
|
||||||
<div
|
|
||||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
|
|
||||||
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
|
||||||
@click="triggerFileInput"
|
|
||||||
@dragover.prevent
|
|
||||||
@dragenter.prevent
|
|
||||||
@drop.prevent="handleFileDrop"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
CSV-Datei hochladen
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
|
||||||
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Unterstützte Formate: .csv
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Column Selection -->
|
|
||||||
<div
|
|
||||||
v-if="csvData.length > 0 && !columnsSelected"
|
|
||||||
class="bg-white rounded-xl shadow-lg p-6 mb-8"
|
|
||||||
>
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
|
||||||
Spalten auswählen
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-gray-600 mb-6">
|
|
||||||
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="(header, index) in csvHeaders"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
:id="`column-${index}`"
|
|
||||||
v-model="selectedColumns[index]"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
:for="`column-${index}`"
|
|
||||||
class="ml-3 text-sm font-medium text-gray-900"
|
|
||||||
>
|
|
||||||
{{ header }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
{{ getColumnPreview(index) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-between items-center">
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
|
|
||||||
</div>
|
|
||||||
<div class="space-x-3">
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
|
||||||
@click="selectAllColumns"
|
|
||||||
>
|
|
||||||
Alle auswählen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
|
||||||
@click="deselectAllColumns"
|
|
||||||
>
|
|
||||||
Alle abwählen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
|
|
||||||
@click="suggestHalleColumns"
|
|
||||||
>
|
|
||||||
Halle-Spalten vorschlagen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:disabled="selectedColumnsCount === 0"
|
|
||||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
|
||||||
@click="confirmColumnSelection"
|
|
||||||
>
|
|
||||||
Auswahl bestätigen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data Preview -->
|
|
||||||
<div
|
|
||||||
v-if="csvData.length > 0 && columnsSelected"
|
|
||||||
class="bg-white rounded-xl shadow-lg p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900">
|
|
||||||
Datenvorschau
|
|
||||||
</h2>
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
|
|
||||||
@click="exportCSV"
|
|
||||||
>
|
|
||||||
CSV exportieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
|
||||||
@click="clearData"
|
|
||||||
>
|
|
||||||
Daten löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data Table -->
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
|
|
||||||
:key="index"
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
{{ header }}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
|
|
||||||
:key="rowIndex"
|
|
||||||
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
v-for="(cell, cellIndex) in row"
|
|
||||||
:key="cellIndex"
|
|
||||||
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
|
|
||||||
>
|
|
||||||
{{ cell }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
|
|
||||||
class="mt-4 text-center text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-gray-600">
|
|
||||||
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p>
|
|
||||||
<p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="text-center py-12 bg-white rounded-xl shadow-lg"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Keine CSV-Daten geladen.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">
|
|
||||||
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Modal -->
|
|
||||||
<div
|
|
||||||
v-if="showUploadModal"
|
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
|
||||||
@click.self="closeUploadModal"
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
CSV-Datei hochladen
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label>
|
|
||||||
<input
|
|
||||||
ref="modalFileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
@change="handleModalFileSelect"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="selectedFile"
|
|
||||||
class="p-3 bg-gray-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-gray-700">
|
|
||||||
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ selectedFile.size }} bytes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-blue-50 p-4 rounded-lg">
|
|
||||||
<h4 class="text-sm font-medium text-blue-800 mb-2">
|
|
||||||
Erwartetes CSV-Format:
|
|
||||||
</h4>
|
|
||||||
<div class="text-xs text-blue-700 space-y-1">
|
|
||||||
<p>• Erste Zeile: Spaltenüberschriften</p>
|
|
||||||
<p>• Spalten: Datum, Mannschaft, Gegner, Ort, Uhrzeit, etc.</p>
|
|
||||||
<p>• Trennzeichen: Komma (,)</p>
|
|
||||||
<p>• Text in Anführungszeichen bei Sonderzeichen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
|
||||||
<button
|
|
||||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
@click="closeUploadModal"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:disabled="!selectedFile"
|
|
||||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
|
||||||
@click="processSelectedFile"
|
|
||||||
>
|
|
||||||
Hochladen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processing Modal -->
|
|
||||||
<div
|
|
||||||
v-if="isProcessing"
|
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
Verarbeitung läuft...
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ processingMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ title: 'CMS: Spielpläne' })
|
|
||||||
|
|
||||||
const fileInput = ref(null)
|
|
||||||
const modalFileInput = ref(null)
|
|
||||||
const showUploadModal = ref(false)
|
|
||||||
const isProcessing = ref(false)
|
|
||||||
const processingMessage = ref('')
|
|
||||||
const isDragOver = ref(false)
|
|
||||||
|
|
||||||
const currentFile = ref(null)
|
|
||||||
const selectedFile = ref(null)
|
|
||||||
const csvData = ref([])
|
|
||||||
const csvHeaders = ref([])
|
|
||||||
const selectedColumns = ref([])
|
|
||||||
const columnsSelected = ref(false)
|
|
||||||
const filteredCsvData = ref([])
|
|
||||||
const filteredCsvHeaders = ref([])
|
|
||||||
const triggerFileInput = () => {
|
|
||||||
fileInput.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileSelect = (event) => {
|
|
||||||
const file = event.target.files[0]
|
|
||||||
if (file) {
|
|
||||||
processFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleModalFileSelect = (event) => {
|
|
||||||
selectedFile.value = event.target.files[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileDrop = (event) => {
|
|
||||||
isDragOver.value = false
|
|
||||||
const file = event.dataTransfer.files[0]
|
|
||||||
if (file && file.type === 'text/csv') {
|
|
||||||
processFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processFile = async (file) => {
|
|
||||||
isProcessing.value = true
|
|
||||||
processingMessage.value = 'Datei wird gelesen...'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await file.text()
|
|
||||||
processingMessage.value = 'CSV wird geparst...'
|
|
||||||
|
|
||||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
|
||||||
if (lines.length < 2) {
|
|
||||||
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSV-Parser: Automatische Erkennung von Trennzeichen (Tab oder Semikolon)
|
|
||||||
const parseCSVLine = (line) => {
|
|
||||||
// Prüfe ob Tab oder Semikolon häufiger vorkommt
|
|
||||||
const tabCount = (line.match(/\t/g) || []).length
|
|
||||||
const semicolonCount = (line.match(/;/g) || []).length
|
|
||||||
|
|
||||||
const delimiter = tabCount > semicolonCount ? '\t' : ';'
|
|
||||||
return line.split(delimiter).map(value => value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header-Zeile parsen
|
|
||||||
csvHeaders.value = parseCSVLine(lines[0])
|
|
||||||
|
|
||||||
// Datenzeilen parsen
|
|
||||||
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
|
||||||
|
|
||||||
// Spaltenauswahl initialisieren (alle ausgewählt)
|
|
||||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
|
||||||
columnsSelected.value = false
|
|
||||||
|
|
||||||
// Datei-Info speichern
|
|
||||||
currentFile.value = {
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified
|
|
||||||
}
|
|
||||||
|
|
||||||
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
isProcessing.value = false
|
|
||||||
showUploadModal.value = false
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Verarbeiten der CSV-Datei:', error)
|
|
||||||
alert('Fehler beim Verarbeiten der CSV-Datei: ' + error.message)
|
|
||||||
isProcessing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processSelectedFile = () => {
|
|
||||||
if (selectedFile.value) {
|
|
||||||
processFile(selectedFile.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFile = () => {
|
|
||||||
currentFile.value = null
|
|
||||||
csvData.value = []
|
|
||||||
csvHeaders.value = []
|
|
||||||
selectedColumns.value = []
|
|
||||||
columnsSelected.value = false
|
|
||||||
filteredCsvData.value = []
|
|
||||||
filteredCsvHeaders.value = []
|
|
||||||
if (fileInput.value) {
|
|
||||||
fileInput.value.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed properties for column selection
|
|
||||||
const selectedColumnsCount = computed(() => {
|
|
||||||
return selectedColumns.value.filter(selected => selected).length
|
|
||||||
})
|
|
||||||
|
|
||||||
const getColumnPreview = (index) => {
|
|
||||||
if (csvData.value.length === 0) return 'Keine Daten'
|
|
||||||
const sampleValues = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== '')
|
|
||||||
return sampleValues.length > 0 ? `Beispiel: ${sampleValues.join(', ')}` : 'Leere Spalte'
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAllColumns = () => {
|
|
||||||
selectedColumns.value = selectedColumns.value.map(() => true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deselectAllColumns = () => {
|
|
||||||
selectedColumns.value = selectedColumns.value.map(() => false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmColumnSelection = () => {
|
|
||||||
// Filtere Daten basierend auf ausgewählten Spalten
|
|
||||||
const selectedIndices = selectedColumns.value.map((selected, index) => selected ? index : -1).filter(index => index !== -1)
|
|
||||||
|
|
||||||
filteredCsvHeaders.value = selectedIndices.map(index => csvHeaders.value[index])
|
|
||||||
filteredCsvData.value = csvData.value.map(row => selectedIndices.map(index => row[index]))
|
|
||||||
|
|
||||||
columnsSelected.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestHalleColumns = () => {
|
|
||||||
// Automatisch Halle-Spalten vorschlagen
|
|
||||||
csvHeaders.value.forEach((header, index) => {
|
|
||||||
const headerLower = header.toLowerCase()
|
|
||||||
if (headerLower.includes('halle') ||
|
|
||||||
headerLower.includes('strasse') ||
|
|
||||||
headerLower.includes('plz') ||
|
|
||||||
headerLower.includes('ort')) {
|
|
||||||
selectedColumns.value[index] = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearData = () => {
|
|
||||||
if (confirm('Möchten Sie alle Daten wirklich löschen?')) {
|
|
||||||
removeFile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportCSV = () => {
|
|
||||||
const dataToExport = columnsSelected.value ? filteredCsvData.value : csvData.value
|
|
||||||
const headersToExport = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
|
||||||
|
|
||||||
if (dataToExport.length === 0) return
|
|
||||||
|
|
||||||
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
|
|
||||||
const csvContent = [
|
|
||||||
headersToExport.join(';'),
|
|
||||||
...dataToExport.map(row => row.join(';'))
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
// Download
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
document.body.removeChild(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
const dataToSave = columnsSelected.value ? filteredCsvData.value : csvData.value
|
|
||||||
const headersToSave = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
|
||||||
|
|
||||||
if (dataToSave.length === 0) {
|
|
||||||
alert('Keine Daten zum Speichern vorhanden.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
|
|
||||||
const csvContent = [
|
|
||||||
headersToSave.join(';'),
|
|
||||||
...dataToSave.map(row => row.join(';'))
|
|
||||||
].join('\n')
|
|
||||||
|
|
||||||
// CSV speichern
|
|
||||||
const response = await fetch('/api/cms/save-csv', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
filename: 'spielplan.csv',
|
|
||||||
content: csvContent
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert('Spielplan erfolgreich gespeichert!')
|
|
||||||
} else {
|
|
||||||
alert('Fehler beim Speichern des Spielplans!')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Speichern:', error)
|
|
||||||
alert('Fehler beim Speichern des Spielplans!')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeUploadModal = () => {
|
|
||||||
showUploadModal.value = false
|
|
||||||
selectedFile.value = null
|
|
||||||
if (modalFileInput.value) {
|
|
||||||
modalFileInput.value.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag and Drop Events
|
|
||||||
const _handleDragEnter = () => {
|
|
||||||
isDragOver.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const _handleDragLeave = () => {
|
|
||||||
isDragOver.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Load existing data if available
|
|
||||||
loadExistingData()
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadExistingData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/data/spielplan.csv')
|
|
||||||
if (response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
|
||||||
|
|
||||||
if (lines.length >= 2) {
|
|
||||||
// Parse existing CSV
|
|
||||||
const parseCSVLine = (line) => {
|
|
||||||
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())
|
|
||||||
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
csvHeaders.value = parseCSVLine(lines[0])
|
|
||||||
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
|
||||||
|
|
||||||
currentFile.value = {
|
|
||||||
name: 'spielplan.csv',
|
|
||||||
size: text.length,
|
|
||||||
lastModified: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fehler beim Laden der Datei, ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
58
pages/cms/sportbetrieb.vue
Normal file
58
pages/cms/sportbetrieb.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-display font-bold text-gray-900">Sportbetrieb verwalten</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Termine, Mannschaften und Spielpläne pflegen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 mb-6">
|
||||||
|
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||||
|
:class="activeTab === tab.id
|
||||||
|
? 'border-primary-600 text-primary-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div>
|
||||||
|
<CmsTermine v-if="activeTab === 'termine'" />
|
||||||
|
<CmsMannschaften v-if="activeTab === 'mannschaften'" />
|
||||||
|
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import CmsTermine from '~/components/cms/CmsTermine.vue'
|
||||||
|
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
|
||||||
|
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Sportbetrieb verwalten – CMS'
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref('termine')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'termine', label: 'Termine' },
|
||||||
|
{ id: 'mannschaften', label: 'Mannschaften' },
|
||||||
|
{ id: 'spielplaene', label: 'Spielpläne' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-full py-16 bg-gray-50">
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
|
|
||||||
Termine verwalten
|
|
||||||
</h1>
|
|
||||||
<div class="w-24 h-1 bg-primary-600 mb-4" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
|
||||||
@click="openAddModal"
|
|
||||||
>
|
|
||||||
<Plus
|
|
||||||
:size="20"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
Termin hinzufügen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="flex items-center justify-center py-12"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
:size="40"
|
|
||||||
class="animate-spin text-primary-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Termine Table -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Datum
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Uhrzeit
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Titel
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Beschreibung
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Kategorie
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Aktionen
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="termin in termine"
|
|
||||||
:key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
|
|
||||||
class="hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{{ formatDate(termin.datum) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{{ termin.uhrzeit || '-' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
|
||||||
{{ termin.titel }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
|
||||||
{{ termin.beschreibung || '-' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
:class="{
|
|
||||||
'bg-blue-100 text-blue-800': termin.kategorie === 'Training',
|
|
||||||
'bg-green-100 text-green-800': termin.kategorie === 'Punktspiel',
|
|
||||||
'bg-purple-100 text-purple-800': termin.kategorie === 'Turnier',
|
|
||||||
'bg-yellow-100 text-yellow-800': termin.kategorie === 'Veranstaltung',
|
|
||||||
'bg-gray-100 text-gray-800': termin.kategorie === 'Sonstiges'
|
|
||||||
}"
|
|
||||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
|
||||||
>
|
|
||||||
{{ termin.kategorie }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
|
||||||
<button
|
|
||||||
class="text-gray-600 hover:text-gray-900"
|
|
||||||
title="Bearbeiten"
|
|
||||||
@click="openEditModal(termin)"
|
|
||||||
>
|
|
||||||
<Pencil :size="18" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="text-red-600 hover:text-red-900"
|
|
||||||
title="Löschen"
|
|
||||||
@click="confirmDelete(termin)"
|
|
||||||
>
|
|
||||||
<Trash2 :size="18" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="termine.length === 0"
|
|
||||||
class="text-center py-12 text-gray-500"
|
|
||||||
>
|
|
||||||
Keine Termine vorhanden.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Modal -->
|
|
||||||
<div
|
|
||||||
v-if="showModal"
|
|
||||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
|
||||||
@click.self="closeModal"
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
|
||||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
|
|
||||||
{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-4"
|
|
||||||
@submit.prevent="saveTermin"
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.datum"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.uhrzeit"
|
|
||||||
type="time"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
|
|
||||||
<select
|
|
||||||
v-model="formData.kategorie"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
<option value="Training">
|
|
||||||
Training
|
|
||||||
</option>
|
|
||||||
<option value="Punktspiel">
|
|
||||||
Punktspiel
|
|
||||||
</option>
|
|
||||||
<option value="Turnier">
|
|
||||||
Turnier
|
|
||||||
</option>
|
|
||||||
<option value="Veranstaltung">
|
|
||||||
Veranstaltung
|
|
||||||
</option>
|
|
||||||
<option value="Sonstiges">
|
|
||||||
Sonstiges
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.titel"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
|
||||||
<textarea
|
|
||||||
v-model="formData.beschreibung"
|
|
||||||
rows="3"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
:disabled="isSaving"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="errorMessage"
|
|
||||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
|
||||||
>
|
|
||||||
<AlertCircle
|
|
||||||
:size="20"
|
|
||||||
class="mr-2"
|
|
||||||
/>
|
|
||||||
{{ errorMessage }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-4 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
:disabled="isSaving"
|
|
||||||
@click="closeModal"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
v-if="isSaving"
|
|
||||||
:size="20"
|
|
||||||
class="animate-spin mr-2"
|
|
||||||
/>
|
|
||||||
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
const termine = ref([])
|
|
||||||
const showModal = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const isEditing = ref(false)
|
|
||||||
const originalTermin = ref(null)
|
|
||||||
|
|
||||||
const formData = ref({
|
|
||||||
datum: '',
|
|
||||||
titel: '',
|
|
||||||
beschreibung: '',
|
|
||||||
kategorie: 'Sonstiges'
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadTermine = async () => {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const response = await $fetch('/api/termine-manage')
|
|
||||||
termine.value = response.termine
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Laden der Termine:', error)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAddModal = () => {
|
|
||||||
formData.value = {
|
|
||||||
datum: '',
|
|
||||||
titel: '',
|
|
||||||
beschreibung: '',
|
|
||||||
kategorie: 'Sonstiges',
|
|
||||||
uhrzeit: ''
|
|
||||||
}
|
|
||||||
showModal.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
isEditing.value = false
|
|
||||||
originalTermin.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
showModal.value = false
|
|
||||||
errorMessage.value = ''
|
|
||||||
isEditing.value = false
|
|
||||||
originalTermin.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTermin = async () => {
|
|
||||||
isSaving.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEditing.value && originalTermin.value) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
datum: originalTermin.value.datum,
|
|
||||||
uhrzeit: originalTermin.value.uhrzeit || '',
|
|
||||||
titel: originalTermin.value.titel,
|
|
||||||
beschreibung: originalTermin.value.beschreibung || '',
|
|
||||||
kategorie: originalTermin.value.kategorie || 'Sonstiges'
|
|
||||||
})
|
|
||||||
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
|
|
||||||
}
|
|
||||||
|
|
||||||
await $fetch('/api/termine-manage', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData.value
|
|
||||||
})
|
|
||||||
|
|
||||||
closeModal()
|
|
||||||
await loadTermine()
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Termins.'
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditModal = (termin) => {
|
|
||||||
formData.value = {
|
|
||||||
datum: termin.datum || '',
|
|
||||||
uhrzeit: termin.uhrzeit || '',
|
|
||||||
titel: termin.titel || '',
|
|
||||||
beschreibung: termin.beschreibung || '',
|
|
||||||
kategorie: termin.kategorie || 'Sonstiges'
|
|
||||||
}
|
|
||||||
originalTermin.value = { ...termin }
|
|
||||||
isEditing.value = true
|
|
||||||
showModal.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = async (termin) => {
|
|
||||||
window.showConfirmModal('Termin löschen', `Möchten Sie den Termin "${termin.titel}" wirklich löschen?`, async () => {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
datum: termin.datum,
|
|
||||||
uhrzeit: termin.uhrzeit || '',
|
|
||||||
titel: termin.titel,
|
|
||||||
beschreibung: termin.beschreibung || '',
|
|
||||||
kategorie: termin.kategorie || 'Sonstiges'
|
|
||||||
})
|
|
||||||
|
|
||||||
await $fetch(`/api/termine-manage?${params.toString()}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
})
|
|
||||||
|
|
||||||
await loadTermine()
|
|
||||||
window.showSuccessModal('Erfolg', 'Termin wurde erfolgreich gelöscht')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Löschen:', error)
|
|
||||||
window.showErrorModal('Fehler', 'Fehler beim Löschen des Termins')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return ''
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadTermine()
|
|
||||||
})
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
layout: 'default'
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: 'Termine verwalten - Harheimer TC',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-full bg-gray-50">
|
|
||||||
<!-- Fixed Header below navigation -->
|
|
||||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
|
||||||
TT-Regeln bearbeiten
|
|
||||||
</h1>
|
|
||||||
<div class="space-x-3">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
|
||||||
@click="save"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed Toolbar below header -->
|
|
||||||
<div
|
|
||||||
class="fixed left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm"
|
|
||||||
style="top: 9.5rem;"
|
|
||||||
>
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
|
|
||||||
<!-- Formatierung -->
|
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('bold')"
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('italic')"
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(1)"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(2)"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(3)"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Listen -->
|
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('insertUnorderedList')"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('insertOrderedList')"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schnellzugriff für Regeln -->
|
|
||||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
|
|
||||||
@click="insertRuleTemplate('generic')"
|
|
||||||
>
|
|
||||||
Neue Regel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
|
|
||||||
@click="insertRuleTemplate('basic')"
|
|
||||||
>
|
|
||||||
Grundregel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
|
|
||||||
@click="insertRuleTemplate('penalty')"
|
|
||||||
>
|
|
||||||
Strafregel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
|
|
||||||
@click="insertRuleTemplate('service')"
|
|
||||||
>
|
|
||||||
Aufschlag
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
|
|
||||||
@click="deleteCurrentRule()"
|
|
||||||
>
|
|
||||||
Regel löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Weitere Tools -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="createLink()"
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="removeFormat()"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content with top padding -->
|
|
||||||
<div
|
|
||||||
class="pb-16"
|
|
||||||
style="padding-top: 12rem;"
|
|
||||||
>
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<!-- Hilfe-Sektion -->
|
|
||||||
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
||||||
<h3 class="text-lg font-semibold text-blue-900 mb-2">
|
|
||||||
💡 So arbeiten Sie mit Regel-Kästchen:
|
|
||||||
</h3>
|
|
||||||
<div class="text-sm text-blue-800 space-y-2">
|
|
||||||
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
|
|
||||||
<ul class="ml-4 space-y-1">
|
|
||||||
<li>• <span class="bg-gray-100 px-2 py-1 rounded text-xs">Neue Regel</span> - Graues Kästchen</li>
|
|
||||||
<li>• <span class="bg-blue-100 px-2 py-1 rounded text-xs">Grundregel</span> - Blaues Kästchen</li>
|
|
||||||
<li>• <span class="bg-green-100 px-2 py-1 rounded text-xs">Strafregel</span> - Grünes Kästchen</li>
|
|
||||||
<li>• <span class="bg-yellow-100 px-2 py-1 rounded text-xs">Aufschlag</span> - Gelbes Kästchen</li>
|
|
||||||
</ul>
|
|
||||||
<p><strong>2. Kästchen löschen:</strong> Klicken Sie in ein Kästchen und dann auf <span class="bg-red-100 px-2 py-1 rounded text-xs">Regel löschen</span></p>
|
|
||||||
<p><strong>3. Kästchen bearbeiten:</strong> Klicken Sie direkt in die Texte und bearbeiten Sie sie</p>
|
|
||||||
<p><strong>4. Grid-Layout:</strong> Kästchen werden automatisch im Grid-Layout angeordnet</p>
|
|
||||||
<p class="text-xs text-blue-600 mt-2">
|
|
||||||
💡 <strong>Tipp:</strong> Neue Kästchen werden automatisch in das bestehende Grid eingefügt!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
|
||||||
<div
|
|
||||||
ref="editor"
|
|
||||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
|
||||||
contenteditable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ title: 'CMS: TT-Regeln' })
|
|
||||||
|
|
||||||
const editor = ref(null)
|
|
||||||
const initialHtml = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const data = await $fetch('/api/config')
|
|
||||||
initialHtml.value = data?.seiten?.ttRegeln || ''
|
|
||||||
if (editor.value) editor.value.innerHTML = initialHtml.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const html = editor.value?.innerHTML || ''
|
|
||||||
const current = await $fetch('/api/config')
|
|
||||||
const updated = { ...current, seiten: { ...(current.seiten || {}), ttRegeln: html } }
|
|
||||||
try {
|
|
||||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
|
||||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch {
|
|
||||||
// Modal nicht verfügbar, ignorieren
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
|
||||||
// Modal nicht verfügbar, ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function format(cmd) {
|
|
||||||
document.execCommand(cmd, false, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHeader(level) {
|
|
||||||
document.execCommand('formatBlock', false, 'H' + level)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLink() {
|
|
||||||
const url = prompt('URL eingeben:')
|
|
||||||
if (!url) return
|
|
||||||
document.execCommand('createLink', false, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFormat() {
|
|
||||||
document.execCommand('removeFormat', false, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertRuleTemplate(type) {
|
|
||||||
const editorElement = editor.value
|
|
||||||
if (!editorElement) return
|
|
||||||
|
|
||||||
let template = ''
|
|
||||||
let bgColor = 'bg-gray-50'
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'generic':
|
|
||||||
template = `
|
|
||||||
<div class="text-center p-6 bg-gray-50 rounded-lg">
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Regel</h3>
|
|
||||||
<p class="text-gray-600 text-sm">[Regeltext hier eingeben]</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
bgColor = 'bg-gray-50'
|
|
||||||
break
|
|
||||||
case 'basic':
|
|
||||||
template = `
|
|
||||||
<div class="text-center p-6 bg-blue-50 rounded-lg">
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Grundregel</h3>
|
|
||||||
<p class="text-gray-600 text-sm"><strong>Regel:</strong> [Regeltext hier eingeben]<br><strong>Beschreibung:</strong> [Detaillierte Beschreibung hier eingeben]<br><strong>Anwendung:</strong> [Wann gilt diese Regel?]</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
bgColor = 'bg-blue-50'
|
|
||||||
break
|
|
||||||
case 'penalty':
|
|
||||||
template = `
|
|
||||||
<div class="text-center p-6 bg-green-50 rounded-lg">
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Strafregel</h3>
|
|
||||||
<p class="text-gray-600 text-sm"><strong>Verstoß:</strong> [Was ist der Verstoß?]<br><strong>Strafe:</strong> [Welche Strafe wird verhängt?]<br><strong>Häufigkeit:</strong> [Bei wiederholten Verstößen?]</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
// bgColor = 'bg-green-50' // Nicht verwendet
|
|
||||||
break
|
|
||||||
case 'service':
|
|
||||||
template = `
|
|
||||||
<div class="text-center p-6 bg-yellow-50 rounded-lg">
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Aufschlagregel</h3>
|
|
||||||
<p class="text-gray-600 text-sm"><strong>Regel:</strong> [Aufschlagregel hier eingeben]<br><strong>Technik:</strong> [Wie muss der Aufschlag ausgeführt werden?]<br><strong>Fehler:</strong> [Was gilt als Fehler?]</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
// bgColor = 'bg-yellow-50'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Editor fokussieren
|
|
||||||
editorElement.focus()
|
|
||||||
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (selection.rangeCount > 0) {
|
|
||||||
const range = selection.getRangeAt(0)
|
|
||||||
|
|
||||||
// Prüfen ob der Cursor im Editor ist
|
|
||||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
|
||||||
// Aktuelles Element finden
|
|
||||||
let currentElement = range.commonAncestorContainer
|
|
||||||
|
|
||||||
// Falls es ein Text-Node ist, zum Parent-Element gehen
|
|
||||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
|
||||||
currentElement = currentElement.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zum spezifischen Container navigieren mit den Klassen "grid md:grid-cols-2 lg:grid-cols-3 gap-6"
|
|
||||||
let targetContainer = currentElement
|
|
||||||
while (targetContainer && !targetContainer.classList.contains('grid')) {
|
|
||||||
targetContainer = targetContainer.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüfen ob es der richtige Container ist
|
|
||||||
if (targetContainer &&
|
|
||||||
targetContainer.classList.contains('md:grid-cols-2') &&
|
|
||||||
targetContainer.classList.contains('lg:grid-cols-3') &&
|
|
||||||
targetContainer.classList.contains('gap-6')) {
|
|
||||||
|
|
||||||
// Wir sind im richtigen Container - neues Kästchen hinzufügen
|
|
||||||
const tempDiv = document.createElement('div')
|
|
||||||
tempDiv.innerHTML = template
|
|
||||||
|
|
||||||
// Neues Kästchen in den Container einfügen
|
|
||||||
// Suche nach dem ersten Element-Node (nicht Text-Node)
|
|
||||||
let newCard = null
|
|
||||||
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
|
||||||
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
|
|
||||||
newCard = tempDiv.childNodes[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newCard) {
|
|
||||||
targetContainer.appendChild(newCard)
|
|
||||||
|
|
||||||
// Cursor in das neue Kästchen setzen
|
|
||||||
const newRange = document.createRange()
|
|
||||||
const titleElement = newCard.querySelector('h3')
|
|
||||||
if (titleElement) {
|
|
||||||
newRange.setStart(titleElement, 0)
|
|
||||||
newRange.collapse(true)
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(newRange)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('No valid element found in template');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Spezifischer Container nicht gefunden - am Ende einfügen
|
|
||||||
editorElement.innerHTML += template
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Cursor ist nicht im Editor - Template am Ende einfügen
|
|
||||||
editorElement.innerHTML += template
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Keine Auswahl - Template am Ende einfügen
|
|
||||||
editorElement.innerHTML += template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteCurrentRule() {
|
|
||||||
const editorElement = editor.value
|
|
||||||
if (!editorElement) return
|
|
||||||
|
|
||||||
// Editor fokussieren
|
|
||||||
editorElement.focus()
|
|
||||||
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (selection.rangeCount > 0) {
|
|
||||||
const range = selection.getRangeAt(0)
|
|
||||||
|
|
||||||
// Prüfen ob der Cursor im Editor ist
|
|
||||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
|
||||||
// Aktuelles Element finden
|
|
||||||
let currentElement = range.commonAncestorContainer
|
|
||||||
|
|
||||||
// Falls es ein Text-Node ist, zum Parent-Element gehen
|
|
||||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
|
||||||
currentElement = currentElement.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zum Grid-Kästchen navigieren
|
|
||||||
let cardElement = currentElement
|
|
||||||
while (cardElement && !cardElement.classList.contains('text-center')) {
|
|
||||||
cardElement = cardElement.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardElement && cardElement.classList.contains('text-center')) {
|
|
||||||
// Grid-Kästchen gefunden - löschen
|
|
||||||
cardElement.remove()
|
|
||||||
|
|
||||||
// Cursor in das nächste Kästchen oder Grid setzen
|
|
||||||
const gridContainer = editorElement.querySelector('.grid')
|
|
||||||
if (gridContainer && gridContainer.children.length > 0) {
|
|
||||||
const firstCard = gridContainer.firstElementChild
|
|
||||||
const titleElement = firstCard.querySelector('h3')
|
|
||||||
if (titleElement) {
|
|
||||||
const newRange = document.createRange()
|
|
||||||
newRange.setStart(titleElement, 0)
|
|
||||||
newRange.collapse(true)
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(newRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-full bg-gray-50">
|
|
||||||
<!-- Fixed Header below navigation -->
|
|
||||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
|
||||||
Über uns bearbeiten
|
|
||||||
</h1>
|
|
||||||
<div class="space-x-3">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
|
||||||
@click="save"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed Toolbar below header -->
|
|
||||||
<div class="fixed top-[9.5rem] left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('bold')"
|
|
||||||
>
|
|
||||||
<strong>B</strong>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('italic')"
|
|
||||||
>
|
|
||||||
<em>I</em>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(1)"
|
|
||||||
>
|
|
||||||
H1
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(2)"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="formatHeader(3)"
|
|
||||||
>
|
|
||||||
H3
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('insertUnorderedList')"
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="format('insertOrderedList')"
|
|
||||||
>
|
|
||||||
1.
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="createLink()"
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
|
||||||
@click="removeFormat()"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content with top padding -->
|
|
||||||
<div class="pt-36 sm:pt-44 pb-16">
|
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
|
||||||
<div
|
|
||||||
ref="editor"
|
|
||||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
|
||||||
contenteditable
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ title: 'CMS: Über uns' })
|
|
||||||
|
|
||||||
const editor = ref(null)
|
|
||||||
const initialHtml = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const data = await $fetch('/api/config')
|
|
||||||
initialHtml.value = data?.seiten?.ueberUns || ''
|
|
||||||
if (editor.value) editor.value.innerHTML = initialHtml.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const html = editor.value?.innerHTML || ''
|
|
||||||
const current = await $fetch('/api/config')
|
|
||||||
const updated = { ...current, seiten: { ...(current.seiten || {}), ueberUns: html } }
|
|
||||||
try {
|
|
||||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
|
||||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
|
|
||||||
// Modal nicht verfügbar, ignorieren
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
|
||||||
// Modal nicht verfügbar, ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function format(cmd) {
|
|
||||||
document.execCommand(cmd, false, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHeader(level) {
|
|
||||||
document.execCommand('formatBlock', false, 'H' + level)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLink() {
|
|
||||||
const url = prompt('URL eingeben:')
|
|
||||||
if (!url) return
|
|
||||||
document.execCommand('createLink', false, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFormat() {
|
|
||||||
document.execCommand('removeFormat', false, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<span class="text-sm text-gray-500">oder</span>
|
<span class="text-sm text-gray-500">oder</span>
|
||||||
<a
|
<a
|
||||||
href="/satzung"
|
href="/verein/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"
|
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"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
107
scripts/generate-galerie-previews.js
Normal file
107
scripts/generate-galerie-previews.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_DIR = getDataPath('galerie')
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
|
||||||
|
async function readJsonArray(file) {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(file, 'utf-8')
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') return []
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson(file, obj) {
|
||||||
|
await fs.writeFile(file, JSON.stringify(obj, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await fs.mkdir(path.join(GALERIE_DIR, 'originals'), { recursive: true })
|
||||||
|
await fs.mkdir(path.join(GALERIE_DIR, 'previews'), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(p) {
|
||||||
|
try {
|
||||||
|
await fs.access(p)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePreviewForEntry(entry, size) {
|
||||||
|
const original = path.join(GALERIE_DIR, 'originals', entry.filename)
|
||||||
|
if (!(await fileExists(original))) return { ok: false, reason: 'missing original' }
|
||||||
|
|
||||||
|
const previewFilename = entry.previewFilename && String(entry.previewFilename).trim() !== ''
|
||||||
|
? entry.previewFilename
|
||||||
|
: `preview_${entry.filename}`
|
||||||
|
|
||||||
|
const preview = path.join(GALERIE_DIR, 'previews', previewFilename)
|
||||||
|
|
||||||
|
await sharp(original)
|
||||||
|
.rotate()
|
||||||
|
.resize(size, size, {
|
||||||
|
fit: 'cover',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 82, mozjpeg: true })
|
||||||
|
.toFile(preview)
|
||||||
|
|
||||||
|
return { ok: true, previewFilename }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const size = Number(process.env.GALERIE_PREVIEW_SIZE || 300)
|
||||||
|
await ensureDirs()
|
||||||
|
|
||||||
|
const entries = await readJsonArray(GALERIE_METADATA)
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log('Keine Galerie-Metadaten gefunden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = 0
|
||||||
|
let generated = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry || !entry.filename) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await generatePreviewForEntry(entry, size)
|
||||||
|
if (!res.ok) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
generated++
|
||||||
|
|
||||||
|
if (entry.previewFilename !== res.previewFilename) {
|
||||||
|
entry.previewFilename = res.previewFilename
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed > 0) await writeJson(GALERIE_METADATA, entries)
|
||||||
|
|
||||||
|
console.log(`Previews erzeugt: ${generated}, übersprungen: ${skipped}, metadata-updates: ${changed}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
96
scripts/migrate-public-galerie-to-metadata.js
Normal file
96
scripts/migrate-public-galerie-to-metadata.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
const allowed = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'])
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_DIR = getDataPath('galerie')
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
const PUBLIC_GALERIE_DIR = path.join(process.cwd(), 'public', 'galerie')
|
||||||
|
|
||||||
|
function titleFromFilename(filename) {
|
||||||
|
const nameWithoutExt = path.parse(filename).name
|
||||||
|
return nameWithoutExt.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonArray(file) {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(file, 'utf-8')
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ENOENT') return []
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await fs.mkdir(path.join(GALERIE_DIR, 'originals'), { recursive: true })
|
||||||
|
await fs.mkdir(path.join(GALERIE_DIR, 'previews'), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureDirs()
|
||||||
|
|
||||||
|
const entries = await readJsonArray(GALERIE_METADATA)
|
||||||
|
const existingByName = new Map(entries.map(e => [e.filename, e]))
|
||||||
|
|
||||||
|
let files = []
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(PUBLIC_GALERIE_DIR)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('public/galerie nicht gefunden:', PUBLIC_GALERIE_DIR)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = files.filter(f => allowed.has(path.extname(f).toLowerCase()))
|
||||||
|
let migrated = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const filename of candidates) {
|
||||||
|
if (existingByName.has(filename)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomUUID()
|
||||||
|
const title = titleFromFilename(filename)
|
||||||
|
|
||||||
|
const src = path.join(PUBLIC_GALERIE_DIR, filename)
|
||||||
|
const dest = path.join(GALERIE_DIR, 'originals', filename)
|
||||||
|
|
||||||
|
await fs.rename(src, dest)
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
id,
|
||||||
|
filename,
|
||||||
|
previewFilename: `preview_${filename}`,
|
||||||
|
title,
|
||||||
|
description: '',
|
||||||
|
isPublic: true,
|
||||||
|
uploadedBy: 'migration',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
originalName: filename
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(meta)
|
||||||
|
existingByName.set(filename, meta)
|
||||||
|
migrated++
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(GALERIE_METADATA, JSON.stringify(entries, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
console.log(`Fertig. Migriert: ${migrated}, übersprungen: ${skipped}`)
|
||||||
|
console.log('Hinweis: previews werden nicht automatisch erzeugt. Für echte previews bitte neu hochladen oder ein separates preview-generator script verwenden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -96,112 +96,49 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Zusätzliche Validierung: Magic-Bytes prüfen (mimetype kann gespooft sein)
|
// Zusätzliche Validierung: Magic-Bytes prüfen (mimetype kann gespooft sein)
|
||||||
await assertPdfMagicHeader(file.path)
|
await assertPdfMagicHeader(file.path)
|
||||||
|
|
||||||
// PDF-Text extrahieren mit pdftotext (falls verfügbar) oder Fallback
|
// 1. Versuche, den Text mit pdftotext zu extrahieren
|
||||||
let extractedText = ''
|
let extractedText = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Versuche pdftotext zu verwenden (falls auf dem System installiert)
|
// UTF-8 erzwingen, Ausgabe nach stdout
|
||||||
const { stdout } = await execAsync(`pdftotext "${file.path}" -`)
|
const { stdout } = await execAsync(`pdftotext -enc UTF-8 "${file.path}" -`)
|
||||||
extractedText = stdout
|
extractedText = stdout || ''
|
||||||
} catch (_error) {
|
} catch (err) {
|
||||||
console.log('pdftotext nicht verfügbar, verwende Fallback-Text')
|
console.error('pdftotext Fehler beim Verarbeiten der Satzung:', err)
|
||||||
// Fallback: Verwende den bekannten Satzungsinhalt
|
throw createError({
|
||||||
extractedText = `Vereinssatzung
|
statusCode: 500,
|
||||||
|
statusMessage: 'Die Satzung konnte nicht aus dem PDF gelesen werden (pdftotext-Fehler). Bitte den Server-Administrator kontaktieren.'
|
||||||
Die Satzung des Harheimer Tischtennis Clubs regelt die Grundlagen unseres Vereins.
|
})
|
||||||
|
|
||||||
§ 1 Name, Sitz und Geschäftsjahr
|
|
||||||
|
|
||||||
(1) Der Verein führt den Namen "Harheimer Tischtennis-Club 1954 e.V." (HTC).
|
|
||||||
|
|
||||||
(2) Der Verein hat seinen Sitz in Frankfurt am Main.
|
|
||||||
|
|
||||||
(3) Das Geschäftsjahr ist das Kalenderjahr.
|
|
||||||
|
|
||||||
§ 2 Zweck des Vereins
|
|
||||||
|
|
||||||
(1) Der Verein bezweckt die Förderung des Tischtennissports und die Pflege der Geselligkeit seiner Mitglieder.
|
|
||||||
|
|
||||||
(2) Der Verein ist selbstlos tätig; er verfolgt nicht in erster Linie eigenwirtschaftliche Zwecke.
|
|
||||||
|
|
||||||
§ 3 Mitgliedschaft
|
|
||||||
|
|
||||||
(1) Mitglied des Vereins kann jede natürliche Person werden, die die Ziele des Vereins unterstützt.
|
|
||||||
|
|
||||||
(2) Der Antrag auf Mitgliedschaft ist schriftlich an den Vorstand zu richten.
|
|
||||||
|
|
||||||
(3) Über die Aufnahme entscheidet der Vorstand.
|
|
||||||
|
|
||||||
§ 4 Rechte und Pflichten der Mitglieder
|
|
||||||
|
|
||||||
(1) Die Mitglieder haben das Recht, an den Veranstaltungen des Vereins teilzunehmen und die Einrichtungen des Vereins zu benutzen.
|
|
||||||
|
|
||||||
(2) Die Mitglieder sind verpflichtet, die Satzung und die Beschlüsse der Vereinsorgane zu beachten und den Mitgliedsbeitrag zu entrichten.
|
|
||||||
|
|
||||||
§ 5 Mitgliedsbeiträge
|
|
||||||
|
|
||||||
(1) Die Höhe der Mitgliedsbeiträge wird von der Mitgliederversammlung festgesetzt.
|
|
||||||
|
|
||||||
(2) Die Mitgliedsbeiträge sind im Voraus zu entrichten.
|
|
||||||
|
|
||||||
§ 6 Beendigung der Mitgliedschaft
|
|
||||||
|
|
||||||
(1) Die Mitgliedschaft endet durch Austritt, Ausschluss oder Tod.
|
|
||||||
|
|
||||||
(2) Der Austritt erfolgt durch schriftliche Erklärung gegenüber dem Vorstand.
|
|
||||||
|
|
||||||
(3) Ein Mitglied kann aus wichtigem Grund ausgeschlossen werden.
|
|
||||||
|
|
||||||
§ 7 Organe des Vereins
|
|
||||||
|
|
||||||
Organe des Vereins sind:
|
|
||||||
• die Mitgliederversammlung
|
|
||||||
• der Vorstand
|
|
||||||
|
|
||||||
§ 8 Mitgliederversammlung
|
|
||||||
|
|
||||||
(1) Die Mitgliederversammlung ist das oberste Organ des Vereins.
|
|
||||||
|
|
||||||
(2) Sie wird vom Vorsitzenden mindestens einmal im Jahr einberufen.
|
|
||||||
|
|
||||||
(3) Die Mitgliederversammlung beschließt über alle wichtigen Angelegenheiten des Vereins.
|
|
||||||
|
|
||||||
§ 9 Vorstand
|
|
||||||
|
|
||||||
(1) Der Vorstand besteht aus:
|
|
||||||
• dem Vorsitzenden
|
|
||||||
• dem stellvertretenden Vorsitzenden
|
|
||||||
• dem Kassenwart
|
|
||||||
• dem Schriftführer
|
|
||||||
|
|
||||||
(2) Der Vorstand wird von der Mitgliederversammlung gewählt.
|
|
||||||
|
|
||||||
(3) Der Vorstand führt die Geschäfte des Vereins.
|
|
||||||
|
|
||||||
§ 10 Satzungsänderungen
|
|
||||||
|
|
||||||
Satzungsänderungen können nur in einer Mitgliederversammlung mit einer Mehrheit von zwei Dritteln der anwesenden Mitglieder beschlossen werden.
|
|
||||||
|
|
||||||
§ 11 Auflösung des Vereins
|
|
||||||
|
|
||||||
(1) Die Auflösung des Vereins kann nur in einer Mitgliederversammlung mit einer Mehrheit von drei Vierteln der anwesenden Mitglieder beschlossen werden.
|
|
||||||
|
|
||||||
(2) Bei Auflösung des Vereins fällt das Vereinsvermögen an eine gemeinnützige Organisation.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text in HTML-Format konvertieren
|
// Minimale Plausibilitätsprüfung: genug Text & typische Satzungs-Merkmale
|
||||||
const htmlContent = convertTextToHtml(extractedText)
|
const cleaned = extractedText.trim()
|
||||||
|
if (!cleaned || cleaned.length < 500 || !cleaned.includes('§')) {
|
||||||
|
console.error('Satzung: extrahierter Text wirkt unplausibel oder zu kurz:', {
|
||||||
|
length: cleaned.length
|
||||||
|
})
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Die Satzung konnte nicht zuverlässig aus dem PDF gelesen werden. Bitte die PDF-Datei prüfen.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Config aktualisieren
|
// 2. In HTML-Format konvertieren
|
||||||
|
const htmlContent = convertTextToHtml(cleaned)
|
||||||
|
|
||||||
|
// 3. Config aktualisieren (PDF + geparster Inhalt)
|
||||||
const configPath = getDataPath('config.json')
|
const configPath = getDataPath('config.json')
|
||||||
const configData = JSON.parse(await fs.readFile(configPath, 'utf-8'))
|
const configData = JSON.parse(await fs.readFile(configPath, 'utf-8'))
|
||||||
|
|
||||||
|
if (!configData.seiten) {
|
||||||
|
configData.seiten = {}
|
||||||
|
}
|
||||||
|
|
||||||
configData.seiten.satzung = {
|
configData.seiten.satzung = {
|
||||||
pdfUrl: '/documents/satzung.pdf',
|
pdfUrl: '/documents/satzung.pdf',
|
||||||
content: htmlContent
|
content: htmlContent
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(configPath, JSON.stringify(configData, null, 2))
|
await fs.writeFile(configPath, JSON.stringify(configData, null, 2), 'utf-8')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -224,44 +161,154 @@ Satzungsänderungen können nur in einer Mitgliederversammlung mit einer Mehrhei
|
|||||||
// PDF-Text zu HTML konvertieren
|
// PDF-Text zu HTML konvertieren
|
||||||
function convertTextToHtml(text) {
|
function convertTextToHtml(text) {
|
||||||
// Text bereinigen und strukturieren
|
// Text bereinigen und strukturieren
|
||||||
let html = text
|
let cleaned = text
|
||||||
.replace(/\r\n/g, '\n') // Windows-Zeilenumbrüche normalisieren
|
.replace(/\r\n/g, '\n') // Windows-Zeilenumbrüche normalisieren
|
||||||
.replace(/\r/g, '\n') // Mac-Zeilenumbrüche normalisieren
|
.replace(/\r/g, '\n') // Mac-Zeilenumbrüche normalisieren
|
||||||
.replace(/\n\s*\n/g, '\n\n') // Mehrfache Zeilenumbrüche reduzieren
|
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
// Überschriften erkennen und formatieren
|
// Seitenzahlen und Seitenfuß entfernen
|
||||||
html = html.replace(/^(Vereinssatzung|Satzung)$/gm, '<h1>$1</h1>')
|
cleaned = cleaned
|
||||||
html = html.replace(/^(§\s*\d+[^§\n]*)$/gm, '<h2>$1</h2>')
|
.replace(/^Seite\s+\d+\s+von\s+\d+.*$/gm, '')
|
||||||
|
.replace(/^-+\d+-+\s*$/gm, '')
|
||||||
|
.replace(/\n\s*-+\d+-+\s*\n/g, '\n')
|
||||||
|
.replace(/\s*-+\d+-+\s*/g, '')
|
||||||
|
.replace(/zuletzt geändert am \d{2}\.\d{2}\.\d{4}.*$/gm, '')
|
||||||
|
|
||||||
// Absätze erstellen
|
// Zeilenweise aufteilen und leere Zeilen filtern
|
||||||
html = html.split('\n\n').map(paragraph => {
|
let rawLines = cleaned.split('\n').map(l => l.trim()).filter(l => {
|
||||||
paragraph = paragraph.trim()
|
if (!l || l.length === 0) return false
|
||||||
if (!paragraph) return ''
|
if (/^-+\d+-+$/.test(l)) return false
|
||||||
|
if (/^Seite\s+\d+\s+von\s+\d+/.test(l)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
// Überschriften nicht als Paragraphen behandeln
|
// ============================================================
|
||||||
if (paragraph.match(/^<h[1-6]>/) || paragraph.match(/^§\s*\d+/)) {
|
// SCHRITT 1: Zusammengehörige Zeilen zusammenführen
|
||||||
return paragraph
|
// pdftotext trennt oft Nummer/Prefix und Inhalt auf zwei Zeilen
|
||||||
|
// ============================================================
|
||||||
|
const merged = []
|
||||||
|
for (let j = 0; j < rawLines.length; j++) {
|
||||||
|
const line = rawLines[j]
|
||||||
|
const next = j + 1 < rawLines.length ? rawLines[j + 1] : null
|
||||||
|
|
||||||
|
// Fall 1: "§ 1" (nur Paragraphennummer) + nächste Zeile ist der Titel
|
||||||
|
// z.B. "§ 1" + "Name, Sitz und Zweck" → "§ 1 Name, Sitz und Zweck"
|
||||||
|
if (/^§\s*\d+\s*$/.test(line) && next && !next.match(/^§/) && !next.match(/^\d+\.\s/)) {
|
||||||
|
merged.push(line + ' ' + next)
|
||||||
|
j++ // nächste Zeile überspringen
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen erkennen
|
// Fall 2: "1." (nur Nummer mit Punkt) + nächste Zeile ist der Text
|
||||||
if (paragraph.includes('•') || paragraph.includes('-') || paragraph.match(/^\d+\./)) {
|
// z.B. "1." + "Der Harheimer TC..." → "1. Der Harheimer TC..."
|
||||||
const listItems = paragraph.split(/\n/).map(item => {
|
if (/^\d+\.\s*$/.test(line) && next) {
|
||||||
item = item.trim()
|
merged.push(line + ' ' + next)
|
||||||
if (item.match(/^[•-]\s/) || item.match(/^\d+\.\s/)) {
|
j++
|
||||||
return `<li>${item.replace(/^[•-]\s/, '').replace(/^\d+\.\s/, '')}</li>`
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 3: "a)" (nur Buchstabe mit Klammer) + nächste Zeile ist der Text
|
||||||
|
// z.B. "a)" + "Die Bestimmungen..." → "a) Die Bestimmungen..."
|
||||||
|
if (/^[a-z]\)\s*$/i.test(line) && next) {
|
||||||
|
merged.push(line + ' ' + next)
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Zusammenführung nötig
|
||||||
|
merged.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SCHRITT 2: HTML-Elemente erzeugen
|
||||||
|
// ============================================================
|
||||||
|
const result = []
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < merged.length) {
|
||||||
|
const line = merged[i]
|
||||||
|
|
||||||
|
// Überschriften erkennen (§1, § 2, etc.)
|
||||||
|
if (line.match(/^§\s*\d+/)) {
|
||||||
|
result.push(`<h2>${line}</h2>`)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob wir eine Liste mit a), b), c) haben
|
||||||
|
// Suche nach einem Muster wie "2. Text:" gefolgt von "a) ...", "b) ...", etc.
|
||||||
|
if (line.match(/^\d+\.\s+.*:$/) && i + 1 < merged.length && merged[i + 1].match(/^[a-z]\)\s+/i)) {
|
||||||
|
// Einleitender Text für die Liste (ohne Nummer)
|
||||||
|
const introText = line.replace(/^\d+\.\s+/, '')
|
||||||
|
const listItems = []
|
||||||
|
i++
|
||||||
|
|
||||||
|
// Sammle alle Listenpunkte a), b), c) ...
|
||||||
|
while (i < merged.length && merged[i].match(/^[a-z]\)\s+/i)) {
|
||||||
|
const itemText = merged[i].replace(/^[a-z]\)\s+/i, '').trim()
|
||||||
|
if (itemText) {
|
||||||
|
listItems.push(itemText)
|
||||||
}
|
}
|
||||||
return `<li>${item}</li>`
|
i++
|
||||||
}).join('')
|
}
|
||||||
return `<ul>${listItems}</ul>`
|
|
||||||
|
if (listItems.length > 0) {
|
||||||
|
const listHtml = listItems.map(item => `<li>${item}</li>`).join('')
|
||||||
|
result.push(`<p><strong>${introText}</strong></p><ul>${listHtml}</ul>`)
|
||||||
|
} else {
|
||||||
|
result.push(`<p>${line}</p>`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelne Listenpunkte a), b), c) erkennen
|
||||||
|
if (line.match(/^[a-z]\)\s+/i)) {
|
||||||
|
const items = []
|
||||||
|
while (i < merged.length && merged[i].match(/^[a-z]\)\s+/i)) {
|
||||||
|
const itemText = merged[i].replace(/^[a-z]\)\s+/i, '').trim()
|
||||||
|
if (itemText) {
|
||||||
|
items.push(itemText)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
const listHtml = items.map(item => `<li>${item}</li>`).join('')
|
||||||
|
result.push(`<ul>${listHtml}</ul>`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nummerierte Listen (1., 2., 3.) - aber nur wenn mehrere aufeinander folgen
|
||||||
|
if (line.match(/^\d+\.\s+/) && i + 1 < merged.length && merged[i + 1].match(/^\d+\.\s+/)) {
|
||||||
|
const items = []
|
||||||
|
while (i < merged.length && merged[i].match(/^\d+\.\s+/)) {
|
||||||
|
const itemText = merged[i].replace(/^\d+\.\s+/, '').trim()
|
||||||
|
// Prüfe ob es eine Einleitung für eine Unterliste ist (endet mit ":")
|
||||||
|
if (itemText.endsWith(':') && i + 1 < merged.length && merged[i + 1].match(/^[a-z]\)\s+/i)) {
|
||||||
|
break // Wird oben als Einleitung + Unterliste behandelt
|
||||||
|
}
|
||||||
|
if (itemText) {
|
||||||
|
items.push(itemText)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
const listHtml = items.map(item => `<li>${item}</li>`).join('')
|
||||||
|
result.push(`<ol>${listHtml}</ol>`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normale Absätze
|
// Normale Absätze
|
||||||
return `<p>${paragraph.replace(/\n/g, '<br>')}</p>`
|
result.push(`<p>${line}</p>`)
|
||||||
}).join('\n')
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
// Mehrfache Zeilenumbrüche entfernen
|
let html = result.join('\n')
|
||||||
html = html.replace(/\n{3,}/g, '\n\n')
|
|
||||||
|
|
||||||
return html
|
// Leere Absätze entfernen
|
||||||
|
html = html.replace(/<p>\s*<\/p>/g, '')
|
||||||
|
html = html.replace(/<p><\/p>/g, '')
|
||||||
|
|
||||||
|
return html.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
83
server/api/media/galerie/[id].get.js
Normal file
83
server/api/media/galerie/[id].get.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getUserFromToken, verifyToken } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
const getDataPath = (filename) => {
|
||||||
|
const cwd = process.cwd()
|
||||||
|
if (cwd.endsWith('.output')) return path.join(cwd, '../server/data', filename)
|
||||||
|
return path.join(cwd, 'server/data', filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GALERIE_DIR = getDataPath('galerie')
|
||||||
|
const GALERIE_METADATA = getDataPath('galerie-metadata.json')
|
||||||
|
|
||||||
|
async function readGalerieMetadata() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(GALERIE_METADATA, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLoggedIn(event) {
|
||||||
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
if (!token) return false
|
||||||
|
const decoded = verifyToken(token)
|
||||||
|
if (!decoded) return false
|
||||||
|
const user = await getUserFromToken(token)
|
||||||
|
return !!(user && user.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const imageId = getRouterParam(event, 'id')
|
||||||
|
const query = getQuery(event)
|
||||||
|
const preview = query.preview === 'true'
|
||||||
|
|
||||||
|
if (!imageId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Bild-ID erforderlich' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = await readGalerieMetadata()
|
||||||
|
const image = metadata.find(img => img.id === imageId)
|
||||||
|
if (!image) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Bild nicht gefunden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.isPublic) {
|
||||||
|
const ok = await isLoggedIn(event)
|
||||||
|
if (!ok) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = preview ? image.previewFilename : image.filename
|
||||||
|
const sanitized = path.basename(path.normalize(String(filename || '')))
|
||||||
|
if (!sanitized || sanitized.includes('..')) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Dateiname' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const subdir = preview ? 'previews' : 'originals'
|
||||||
|
const filePath = path.join(GALERIE_DIR, subdir, sanitized)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
} catch (e) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Bilddatei nicht gefunden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(sanitized).toLowerCase()
|
||||||
|
const mimeTypes = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml'
|
||||||
|
}
|
||||||
|
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||||
|
|
||||||
|
const buf = await fs.readFile(filePath)
|
||||||
|
setHeader(event, 'Content-Type', contentType)
|
||||||
|
setHeader(event, 'Cache-Control', image.isPublic ? 'public, max-age=31536000, immutable' : 'private, max-age=0, no-store')
|
||||||
|
return buf
|
||||||
|
})
|
||||||
66
server/api/media/internal/[...path].get.js
Normal file
66
server/api/media/internal/[...path].get.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import fsp from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// Minimal auth check using existing auth cookie/session
|
||||||
|
function isAuthenticated(event) {
|
||||||
|
try {
|
||||||
|
const token = getCookie(event, 'auth_token') || getCookie(event, 'session_token')
|
||||||
|
return token && String(token).trim() !== ''
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve file path within a non-public internal media directory
|
||||||
|
function resolveInternalPath(reqPath) {
|
||||||
|
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
|
||||||
|
// prevent path traversal
|
||||||
|
const safe = path.normalize(reqPath).replace(/^\/+/, '')
|
||||||
|
return path.join(baseDir, safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// auth gate
|
||||||
|
if (!isAuthenticated(event)) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Nicht autorisiert' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const param = event.context.params?.path
|
||||||
|
const reqPath = Array.isArray(param) ? param.join('/') : String(param || '')
|
||||||
|
if (!reqPath) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Bildpfad fehlt' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveInternalPath(reqPath)
|
||||||
|
// check existence and ensure it stays within baseDir
|
||||||
|
const baseDir = path.join(process.cwd(), 'server', 'private', 'gallery-internal')
|
||||||
|
const resolved = path.resolve(filePath)
|
||||||
|
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Pfad' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = await fsp.stat(resolved)
|
||||||
|
if (!stat.isFile()) throw new Error('not a file')
|
||||||
|
} catch (e) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Datei nicht gefunden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine content type by extension
|
||||||
|
const ext = path.extname(resolved).toLowerCase()
|
||||||
|
const contentType = (
|
||||||
|
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' :
|
||||||
|
ext === '.png' ? 'image/png' :
|
||||||
|
ext === '.gif' ? 'image/gif' :
|
||||||
|
ext === '.webp' ? 'image/webp' :
|
||||||
|
ext === '.svg' ? 'image/svg+xml' :
|
||||||
|
'application/octet-stream'
|
||||||
|
)
|
||||||
|
|
||||||
|
// stream the file
|
||||||
|
const stream = fs.createReadStream(resolved)
|
||||||
|
setHeader(event, 'Content-Type', contentType)
|
||||||
|
setHeader(event, 'Cache-Control', 'private, max-age=0, no-store')
|
||||||
|
return sendStream(event, stream)
|
||||||
|
})
|
||||||
@@ -13,12 +13,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload-Verzeichnis finden
|
// Upload-Verzeichnis finden (intern)
|
||||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
|
const uploadDir = path.join(process.cwd(), '..', 'server', 'data', 'uploads')
|
||||||
console.log('Upload-Verzeichnis:', uploadDir)
|
console.log('Upload-Verzeichnis:', uploadDir)
|
||||||
|
|
||||||
// Alle Dateien im Upload-Verzeichnis durchsuchen
|
// Alle Dateien im Upload-Verzeichnis durchsuchen
|
||||||
const files = await fs.readdir(uploadDir)
|
const files = await fs.readdir(uploadDir)
|
||||||
console.log('Verfügbare Dateien:', files)
|
console.log('Verfügbare Dateien:', files)
|
||||||
console.log('Gesuchte Datei-ID:', fileId)
|
console.log('Gesuchte Datei-ID:', fileId)
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(uploadDir, matchingFile)
|
const filePath = path.join(uploadDir, matchingFile)
|
||||||
|
|
||||||
// Datei lesen
|
// Datei lesen
|
||||||
const fileBuffer = await fs.readFile(filePath)
|
const fileBuffer = await fs.readFile(filePath)
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ Volljährig: ${data.isVolljaehrig ? 'Ja' : 'Nein'}
|
|||||||
Das ausgefüllte Formular ist als Anhang verfügbar.`
|
Das ausgefüllte Formular ist als Anhang verfügbar.`
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
const textPath = path.join(process.cwd(), 'public', 'uploads', `${filename}.txt`)
|
const textPath = path.join(getDataPath('uploads'), `${filename}.txt`)
|
||||||
await fs.writeFile(textPath, textContent, 'utf8')
|
await fs.writeFile(textPath, textContent, 'utf8')
|
||||||
|
|
||||||
return `${filename}.txt`
|
return `${filename}.txt`
|
||||||
@@ -659,28 +659,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
return Buffer.from(pdfBytes)
|
return Buffer.from(pdfBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
let usedTemplate = false
|
let usedTemplate = false
|
||||||
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
|
const uploadsDir = getDataPath('uploads')
|
||||||
await fs.mkdir(uploadsDir, { recursive: true })
|
await fs.mkdir(uploadsDir, { recursive: true })
|
||||||
try {
|
try {
|
||||||
const filled = await fillPdfTemplate(data)
|
const filled = await fillPdfTemplate(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||||
await fs.writeFile(finalPdfPath, filled)
|
await fs.writeFile(finalPdfPath, filled)
|
||||||
// Zusätzlich: Kopie ins repo-root public/uploads legen, falls Nitro cwd anders ist
|
// Do NOT copy filled PDFs into public repo uploads to avoid accidental exposure.
|
||||||
try {
|
|
||||||
const repoRoot = path.resolve(process.cwd(), '..')
|
|
||||||
const repoUploads = path.join(repoRoot, 'public', 'uploads')
|
|
||||||
await fs.mkdir(repoUploads, { recursive: true })
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
|
|
||||||
} catch (_e) {
|
|
||||||
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
|
|
||||||
}
|
|
||||||
usedTemplate = true
|
usedTemplate = true
|
||||||
} catch (templateError) {
|
} catch (templateError) {
|
||||||
// Template konnte nicht verwendet werden -> weiter zum LaTeX-Fallback
|
// Template konnte nicht verwendet werden -> weiter zum LaTeX-Fallback
|
||||||
@@ -720,7 +709,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
// LaTeX-Inhalt generieren
|
// LaTeX-Inhalt generieren
|
||||||
const latexContent = generateLaTeXContent(data)
|
const latexContent = generateLaTeXContent(data)
|
||||||
|
|
||||||
// LaTeX-Datei schreiben
|
// LaTeX-Datei schreiben
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -733,30 +722,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
|
const command = `cd "${tempDir}" && pdflatex -interaction=nonstopmode "${filename}.tex"`
|
||||||
await execAsync(command)
|
await execAsync(command)
|
||||||
|
|
||||||
// PDF-Datei in Uploads-Verzeichnis kopieren
|
// PDF-Datei in Uploads-Verzeichnis kopieren
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
const pdfPath = path.join(tempDir, `${filename}.pdf`)
|
const pdfPath = path.join(tempDir, `${filename}.pdf`)
|
||||||
await fs.mkdir(uploadsDir, { recursive: true })
|
await fs.mkdir(uploadsDir, { recursive: true })
|
||||||
|
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
await fs.copyFile(pdfPath, finalPdfPath)
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
const finalPdfPath = path.join(uploadsDir, `${filename}.pdf`)
|
|
||||||
await fs.copyFile(pdfPath, finalPdfPath)
|
|
||||||
// Kopie ins repo-root public/uploads für bessere Auffindbarkeit
|
|
||||||
try {
|
|
||||||
const repoRoot = path.resolve(process.cwd(), '..')
|
|
||||||
const repoUploads = path.join(repoRoot, 'public', 'uploads')
|
|
||||||
await fs.mkdir(repoUploads, { recursive: true })
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
||||||
await fs.copyFile(finalPdfPath, path.join(repoUploads, `${filename}.pdf`))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Kopie in repo public/uploads fehlgeschlagen:', e.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// E-Mail senden
|
// E-Mail senden
|
||||||
emailResult = await sendMembershipEmail(data, filename, event)
|
emailResult = await sendMembershipEmail(data, filename, event)
|
||||||
@@ -793,14 +766,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
// E-Mail senden (Fallback)
|
// E-Mail senden (Fallback)
|
||||||
const emailResult = await sendMembershipEmail(data, filename, event)
|
const emailResult = await sendMembershipEmail(data, filename, event)
|
||||||
|
|
||||||
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
||||||
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
||||||
console.log('Betreff:', emailResult.subject || '')
|
console.log('Betreff:', emailResult.subject || '')
|
||||||
console.log('Nachricht:', emailResult.message || '')
|
console.log('Nachricht:', emailResult.message || '')
|
||||||
console.log('Upload-Verzeichnis:', path.join(process.cwd(), 'public', 'uploads'))
|
console.log('Upload-Verzeichnis:', getDataPath('uploads'))
|
||||||
|
|
||||||
// Verfügbare Dateien auflisten
|
// Verfügbare Dateien auflisten
|
||||||
const uploadsDir = path.join(process.cwd(), 'public', 'uploads')
|
const uploadsDir = getDataPath('uploads')
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(uploadsDir)
|
const files = await fs.readdir(uploadsDir)
|
||||||
console.log('Verfügbare Dateien:', files)
|
console.log('Verfügbare Dateien:', files)
|
||||||
|
|||||||
@@ -12,16 +12,13 @@ export function getCookieSecureDefault() {
|
|||||||
|
|
||||||
export function getSameSiteDefault() {
|
export function getSameSiteDefault() {
|
||||||
// Cookie SameSite-Konfiguration
|
// Cookie SameSite-Konfiguration
|
||||||
// - 'none': Erlaubt Cookies in Cross-Site-iframes (erfordert Secure: true)
|
// - 'lax': Erlaubt Cookies bei Navigation (Standard)
|
||||||
// - 'lax': Erlaubt Cookies bei Navigation (Standard für Cross-Site)
|
// - 'strict': Blockiert alle Cross-Site-Cookies (sicherste Option)
|
||||||
// - 'strict': Blockiert alle Cross-Site-Cookies (sicherste Option, blockiert iframes)
|
// - 'none': Erlaubt Cookies in Cross-Site-iframes (erfordert Secure: true / HTTPS)
|
||||||
const v = (process.env.COOKIE_SAMESITE || '').toLowerCase().trim()
|
const v = (process.env.COOKIE_SAMESITE || '').toLowerCase().trim()
|
||||||
if (v === 'strict' || v === 'lax' || v === 'none') return v
|
if (v === 'strict' || v === 'lax' || v === 'none') return v
|
||||||
|
|
||||||
// Default: 'none' für Cross-Site-iframes (wenn in iframe eingebettet)
|
return 'lax'
|
||||||
// WICHTIG: SameSite: none erfordert Secure: true (HTTPS)
|
|
||||||
// Falls iframe-Einbettung nicht benötigt wird, kann auf 'strict' oder 'lax' geändert werden
|
|
||||||
return 'none' // Ermöglicht Einbettung in iframes (z.B. von harheimertc.de)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthCookieOptions() {
|
export function getAuthCookieOptions() {
|
||||||
|
|||||||
Reference in New Issue
Block a user