Refactor CMS navigation and remove outdated pages
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s

This commit updates the Navigation component to replace links for "Über uns", "Geschichte", "TT-Regeln", "Satzung", and "Termine" with a consolidated "Inhalte" and "Sportbetrieb" section. Additionally, it removes the corresponding pages for "Geschichte", "Mannschaften", "Satzung", "Termine", and "Spielpläne" to streamline the CMS structure and improve content management efficiency.
This commit is contained in:
Torsten Schulz (local)
2026-02-09 09:37:11 +01:00
parent 04e38aa7d3
commit 73e3d2a5d0
17 changed files with 1484 additions and 3108 deletions

View File

@@ -336,32 +336,11 @@
</NuxtLink>
<div class="border-t border-gray-700 my-1" />
<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"
@click="showCmsDropdown = false"
>
Über uns
</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
Inhalte
</NuxtLink>
<NuxtLink
to="/cms/vereinsmeisterschaften"
@@ -379,25 +358,11 @@
News
</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"
@click="showCmsDropdown = false"
>
Termine
</NuxtLink>
<NuxtLink
to="/cms/mannschaften"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Mannschaften
</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
Sportbetrieb
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/mitglieder"
@@ -773,25 +738,11 @@
News
</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"
@click="isMobileMenuOpen = false"
>
Termine
</NuxtLink>
<NuxtLink
to="/cms/mannschaften"
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"
>
Mannschaften
</NuxtLink>
<NuxtLink
to="/cms/spielplaene"
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"
>
Spielpläne
Sportbetrieb
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/mitglieder"
@@ -801,32 +752,11 @@
Mitglieder
</NuxtLink>
<NuxtLink
to="/cms/ueber-uns"
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"
@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
Inhalte
</NuxtLink>
<NuxtLink
to="/cms/vereinsmeisterschaften"

View File

@@ -1,145 +1,130 @@
<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">
Geschichte 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>
<!-- 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">
Geschichte 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 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>
<!-- 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 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>
<!-- 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>
@@ -147,19 +132,12 @@
<script setup>
import { ref, onMounted } from 'vue'
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'CMS: Geschichte' })
const editor = ref(null)
const initialHtml = ref('')
async function load() {
const data = await $fetch('/api/config')
initialHtml.value = data?.seiten?.geschichte || ''
if (editor.value) editor.value.innerHTML = initialHtml.value
const html = data?.seiten?.geschichte || ''
if (editor.value) editor.value.innerHTML = html
}
async function save() {
@@ -169,11 +147,11 @@ async function save() {
try {
await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
// Modal nicht verfügbar, ignorieren
// Modal nicht verfügbar
}
} catch (error) {
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
}
// 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 Geschichts-Abschnitt navigieren (div mit border-l-4 border-primary-600)
let sectionElement = currentElement
while (sectionElement && sectionElement !== editorElement) {
if (sectionElement.classList &&
@@ -273,11 +246,9 @@ function insertHistoryTemplate(type) {
sectionElement.classList.contains('border-l-4') &&
sectionElement.classList.contains('border-primary-600')) {
// Wir sind in einem Geschichts-Abschnitt - neuen Abschnitt danach einfügen
const tempDiv = document.createElement('div')
tempDiv.innerHTML = template
// Suche nach dem ersten Element-Node (nicht Text-Node)
let newSection = null
for (let i = 0; i < tempDiv.childNodes.length; i++) {
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
@@ -287,14 +258,12 @@ function insertHistoryTemplate(type) {
}
if (newSection) {
// Nach dem aktuellen Abschnitt einfügen
if (sectionElement.nextSibling) {
sectionElement.parentElement.insertBefore(newSection, sectionElement.nextSibling)
} else {
sectionElement.parentElement.appendChild(newSection)
}
// Cursor in das neue Element setzen
const newRange = document.createRange()
const titleElement = newSection.querySelector('h3')
if (titleElement) {
@@ -303,13 +272,10 @@ function insertHistoryTemplate(type) {
selection.removeAllRanges()
selection.addRange(newRange)
}
} else {
console.error('No valid element found in template');
}
} else {
// Kein Geschichts-Abschnitt gefunden - suche nach dem nächsten Geschichts-Abschnitt
let nextSection = null
let walker = document.createTreeWalker(
const walker = document.createTreeWalker(
editorElement,
NodeFilter.SHOW_ELEMENT,
{
@@ -324,7 +290,6 @@ function insertHistoryTemplate(type) {
}
)
// Finde den ersten Geschichts-Abschnitt nach dem aktuellen Element
let node = walker.nextNode()
while (node) {
if (currentElement.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
@@ -337,7 +302,6 @@ function insertHistoryTemplate(type) {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = template
// Suche nach dem ersten Element-Node (nicht Text-Node)
let newSection = null
for (let i = 0; i < tempDiv.childNodes.length; i++) {
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
@@ -348,14 +312,11 @@ function insertHistoryTemplate(type) {
if (newSection) {
if (nextSection) {
// Vor dem nächsten Geschichts-Abschnitt einfügen
nextSection.parentElement.insertBefore(newSection, nextSection)
} else {
// Kein nächster Abschnitt gefunden - am Ende einfügen
editorElement.appendChild(newSection)
}
// Cursor in das neue Element setzen
const newRange = document.createRange()
const titleElement = newSection.querySelector('h3')
if (titleElement) {
@@ -364,16 +325,12 @@ function insertHistoryTemplate(type) {
selection.removeAllRanges()
selection.addRange(newRange)
}
} else {
console.error('No valid element found in 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
}
}
@@ -382,34 +339,27 @@ function deleteCurrentSection() {
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 Geschichts-Abschnitt navigieren (div mit border-l-4 border-primary-600)
let sectionElement = currentElement
while (sectionElement && !(sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600'))) {
sectionElement = sectionElement.parentElement
}
if (sectionElement && sectionElement.classList.contains('border-l-4') && sectionElement.classList.contains('border-primary-600')) {
// Geschichts-Abschnitt gefunden - löschen
sectionElement.remove()
// Cursor in das nächste Element setzen
const nextElement = editorElement.querySelector('.border-l-4.border-primary-600')
if (nextElement) {
const titleElement = nextElement.querySelector('h3')

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -7,9 +7,9 @@
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Über uns -->
<!-- Inhalte (gruppiert) -->
<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"
>
<div class="flex items-center mb-4">
@@ -20,83 +20,11 @@
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Über uns
Inhalte
</h2>
</div>
<p class="text-gray-600">
Seite Über uns" bearbeiten (WYSIWYG)
</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
Über uns, Geschichte, TT-Regeln &amp; Satzung
</p>
</NuxtLink>
<!-- News -->
@@ -120,9 +48,9 @@
</p>
</NuxtLink>
<!-- Termine -->
<!-- Sportbetrieb (gruppiert) -->
<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"
>
<div class="flex items-center mb-4">
@@ -133,32 +61,11 @@
/>
</div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">
Termine
Sportbetrieb
</h2>
</div>
<p class="text-gray-600">
Vereinstermine erstellen und verwalten
</p>
</NuxtLink>
<!-- Mannschaften -->
<NuxtLink
to="/cms/mannschaften"
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
Termine, Mannschaften &amp; Spielpläne
</p>
</NuxtLink>

61
pages/cms/inhalte.vue Normal file
View 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>

View File

@@ -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>

View File

@@ -1,296 +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>
<!-- 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>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import RichTextEditor from '~/components/RichTextEditor.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('')
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
// Einfache Zeitstempel-Simulation
lastUpdated.value = new Date().toLocaleDateString('de-DE')
}
if (satzung?.content) {
// Stelle sicher, dass der Inhalt als String geladen wird
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'
// 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
}
}
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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>