Refactor CMS navigation and remove outdated pages
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:
@@ -1,430 +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">
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const html = editor.value?.innerHTML || ''
|
||||
const current = await $fetch('/api/config')
|
||||
const updated = { ...current, seiten: { ...(current.seiten || {}), geschichte: 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)
|
||||
}
|
||||
|
||||
function insertHistoryTemplate(type) {
|
||||
const editorElement = editor.value
|
||||
if (!editorElement) return
|
||||
|
||||
let template = ''
|
||||
|
||||
switch (type) {
|
||||
case 'generic':
|
||||
template = `
|
||||
<div class="bg-gray-50/30 p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">Neuer Geschichts-Abschnitt</h3>
|
||||
<p class="text-gray-600 mb-3"><strong>Zeitraum:</strong> [Jahr oder Zeitraum]<br><strong>Ereignis:</strong> [Was ist passiert?]<br><strong>Bedeutung:</strong> [Warum war das wichtig?]</p>
|
||||
<p class="text-gray-600"><strong>Details:</strong> [Weitere Informationen hier eingeben...]</p>
|
||||
</div>
|
||||
`
|
||||
break
|
||||
case 'founding':
|
||||
template = `
|
||||
<div class="bg-blue-50/30 p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">Gründung des Vereins</h3>
|
||||
<p class="text-gray-600 mb-3"><strong>Datum:</strong> [Gründungsdatum]<br><strong>Gründer:</strong> [Wer hat den Verein gegründet?]<br><strong>Zweck:</strong> [Was war das Ziel?]</p>
|
||||
<p class="text-gray-600"><strong>Gründungsgeschichte:</strong> [Wie kam es zur Gründung? Was waren die Umstände?]</p>
|
||||
</div>
|
||||
`
|
||||
break
|
||||
case 'milestone':
|
||||
template = `
|
||||
<div class="bg-green-50/30 p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">Wichtiger Meilenstein</h3>
|
||||
<p class="text-gray-600 mb-3"><strong>Jahr:</strong> [Wann ist das passiert?]<br><strong>Ereignis:</strong> [Was war der Meilenstein?]<br><strong>Auswirkung:</strong> [Wie hat das den Verein verändert?]</p>
|
||||
<p class="text-gray-600"><strong>Hintergrund:</strong> [Was führte zu diesem Ereignis? Wie wurde es erreicht?]</p>
|
||||
</div>
|
||||
`
|
||||
break
|
||||
case 'achievement':
|
||||
template = `
|
||||
<div class="bg-yellow-50/30 p-6 rounded-xl shadow-lg border-l-4 border-primary-600">
|
||||
<h3 class="text-xl font-display font-bold text-gray-900 mb-3">Großer Erfolg</h3>
|
||||
<p class="text-gray-600 mb-3"><strong>Jahr:</strong> [Wann war der Erfolg?]<br><strong>Erfolg:</strong> [Was wurde erreicht?]<br><strong>Beteiligte:</strong> [Wer war daran beteiligt?]</p>
|
||||
<p class="text-gray-600"><strong>Details:</strong> [Wie wurde der Erfolg erreicht? Was war besonders bemerkenswert?]</p>
|
||||
</div>
|
||||
`
|
||||
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 &&
|
||||
sectionElement.classList.contains('border-l-4') &&
|
||||
sectionElement.classList.contains('border-primary-600')) {
|
||||
break
|
||||
}
|
||||
sectionElement = sectionElement.parentElement
|
||||
}
|
||||
|
||||
if (sectionElement && sectionElement !== editorElement &&
|
||||
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) {
|
||||
newSection = tempDiv.childNodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
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(
|
||||
editorElement,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: function(node) {
|
||||
if (node.classList &&
|
||||
node.classList.contains('border-l-4') &&
|
||||
node.classList.contains('border-primary-600')) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Finde den ersten Geschichts-Abschnitt nach dem aktuellen Element
|
||||
let node = walker.nextNode()
|
||||
while (node) {
|
||||
if (currentElement.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||
nextSection = node
|
||||
break
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
|
||||
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) {
|
||||
newSection = tempDiv.childNodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
if (titleElement) {
|
||||
const newRange = document.createRange()
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(newRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -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 & 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 & Spielpläne
|
||||
</p>
|
||||
</NuxtLink>
|
||||
|
||||
|
||||
61
pages/cms/inhalte.vue
Normal file
61
pages/cms/inhalte.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-display font-bold text-gray-900">Inhalte verwalten</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Redaktionelle Inhalte der Website bearbeiten</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="activeTab === tab.id
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div>
|
||||
<CmsUeberUns v-if="activeTab === 'ueber-uns'" />
|
||||
<CmsGeschichte v-if="activeTab === 'geschichte'" />
|
||||
<CmsTtRegeln v-if="activeTab === 'tt-regeln'" />
|
||||
<CmsSatzung v-if="activeTab === 'satzung'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import CmsUeberUns from '~/components/cms/CmsUeberUns.vue'
|
||||
import CmsGeschichte from '~/components/cms/CmsGeschichte.vue'
|
||||
import CmsTtRegeln from '~/components/cms/CmsTtRegeln.vue'
|
||||
import CmsSatzung from '~/components/cms/CmsSatzung.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Inhalte verwalten – CMS'
|
||||
})
|
||||
|
||||
const activeTab = ref('ueber-uns')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'ueber-uns', label: 'Über uns' },
|
||||
{ id: 'geschichte', label: 'Geschichte' },
|
||||
{ id: 'tt-regeln', label: 'TT-Regeln' },
|
||||
{ id: 'satzung', label: 'Satzung' }
|
||||
]
|
||||
</script>
|
||||
@@ -1,836 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 bg-gray-50">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
|
||||
Mannschaften verwalten
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-4" />
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<Plus
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/>
|
||||
Mannschaft hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2
|
||||
:size="40"
|
||||
class="animate-spin text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mannschaften Table -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Mannschaft
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Liga
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Staffelleiter
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Mannschaftsführer
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Spieler
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="(mannschaft, index) in mannschaften"
|
||||
:key="index"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||
{{ mannschaft.mannschaft }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ mannschaft.liga }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ mannschaft.staffelleiter }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ mannschaft.mannschaftsfuehrer }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
<div class="max-w-xs truncate">
|
||||
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button
|
||||
class="text-gray-600 hover:text-gray-900"
|
||||
title="Bearbeiten"
|
||||
@click="openEditModal(mannschaft, index)"
|
||||
>
|
||||
<Pencil
|
||||
:size="18"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-900"
|
||||
title="Löschen"
|
||||
@click="confirmDelete(mannschaft, index)"
|
||||
>
|
||||
<Trash2
|
||||
:size="18"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!isLoading && mannschaften.length === 0"
|
||||
class="bg-white rounded-xl shadow-lg p-12 text-center"
|
||||
>
|
||||
<Users
|
||||
:size="48"
|
||||
class="text-gray-400 mx-auto mb-4"
|
||||
/>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
Keine Mannschaften vorhanden
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Fügen Sie die erste Mannschaft hinzu.
|
||||
</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
Mannschaft hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="p-6 space-y-4"
|
||||
@submit.prevent="saveMannschaft"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mannschaft *
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.mannschaft"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Liga *
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.liga"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Staffelleiter
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.staffelleiter"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.telefon"
|
||||
type="tel"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Heimspieltag
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.heimspieltag"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Spielsystem
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.spielsystem"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mannschaftsführer
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.mannschaftsfuehrer"
|
||||
type="text"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Spieler
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-if="formData.spielerListe.length === 0"
|
||||
class="text-sm text-gray-500"
|
||||
>
|
||||
Noch keine Spieler eingetragen.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(spieler, index) in formData.spielerListe"
|
||||
:key="spieler.id"
|
||||
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<input
|
||||
v-model="spieler.name"
|
||||
type="text"
|
||||
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Spielername"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
|
||||
<!-- Reihenfolge -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Nach oben"
|
||||
:disabled="isSaving || index === 0"
|
||||
@click="moveSpielerUp(index)"
|
||||
>
|
||||
<ChevronUp :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Nach unten"
|
||||
:disabled="isSaving || index === formData.spielerListe.length - 1"
|
||||
@click="moveSpielerDown(index)"
|
||||
>
|
||||
<ChevronDown :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Spieler entfernen"
|
||||
:disabled="isSaving"
|
||||
@click="removeSpieler(spieler.id)"
|
||||
>
|
||||
<Trash2 :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Verschieben (kompakt in gleicher Zeile) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="moveTargetBySpielerId[spieler.id]"
|
||||
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
|
||||
title="Mannschaft auswählen"
|
||||
>
|
||||
<option
|
||||
v-for="t in mannschaftenSelectOptions"
|
||||
:key="t"
|
||||
:value="t"
|
||||
>
|
||||
{{ t }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
|
||||
@click="moveSpielerToMannschaft(spieler.id)"
|
||||
title="In ausgewählte Mannschaft verschieben"
|
||||
>
|
||||
<ArrowRight :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
|
||||
:disabled="isSaving"
|
||||
@click="addSpieler()"
|
||||
>
|
||||
<Plus
|
||||
:size="18"
|
||||
class="mr-2"
|
||||
/>
|
||||
Spieler hinzufügen
|
||||
</button>
|
||||
<p class="text-xs text-gray-500">
|
||||
Reihenfolge per ↑/↓ ändern. Verschieben nur bei bestehenden Mannschaften.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Weitere Informationen (Link)
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.weitere_informationen_link"
|
||||
type="url"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="https://..."
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
:disabled="isSaving"
|
||||
@click="closeModal"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<Loader2
|
||||
v-if="isSaving"
|
||||
:size="20"
|
||||
class="animate-spin mr-2"
|
||||
/>
|
||||
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const mannschaften = ref([])
|
||||
const showModal = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isEditing = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
|
||||
const formData = ref({
|
||||
mannschaft: '',
|
||||
liga: '',
|
||||
staffelleiter: '',
|
||||
telefon: '',
|
||||
heimspieltag: '',
|
||||
spielsystem: '',
|
||||
mannschaftsfuehrer: '',
|
||||
spielerListe: [],
|
||||
weitere_informationen_link: '',
|
||||
letzte_aktualisierung: ''
|
||||
})
|
||||
|
||||
// Für Verschieben-UI (Combobox pro Spieler)
|
||||
const moveTargetBySpielerId = ref({})
|
||||
// Pending-Änderungen für andere Teams (wird erst beim Speichern angewendet)
|
||||
const pendingSpielerNamesByTeamIndex = ref({}) // { [index: number]: string[] }
|
||||
|
||||
function nowIsoDate() {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function newSpielerItem(name = '') {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
function parseSpielerString(spielerString) {
|
||||
if (!spielerString) return []
|
||||
return String(spielerString)
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(name => newSpielerItem(name))
|
||||
}
|
||||
|
||||
function serializeSpielerList(spielerListe) {
|
||||
return (spielerListe || [])
|
||||
.map(s => (s?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
function serializeSpielerNames(spielerNames) {
|
||||
return (spielerNames || [])
|
||||
.map(s => String(s || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
async function fetchCsvText(url) {
|
||||
const attempt = async () => {
|
||||
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
|
||||
const response = await fetch(withBuster, { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
return await response.text()
|
||||
}
|
||||
|
||||
try {
|
||||
return await attempt()
|
||||
} catch (e) {
|
||||
// 1 Retry: hilft bei Firefox NS_ERROR_NET_PARTIAL_TRANSFER direkt nach Speichern
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
return await attempt()
|
||||
}
|
||||
}
|
||||
|
||||
const mannschaftenSelectOptions = computed(() => {
|
||||
const current = (formData.value.mannschaft || '').trim()
|
||||
const names = mannschaften.value
|
||||
.map(m => (m?.mannschaft || '').trim())
|
||||
.filter(Boolean)
|
||||
return [...new Set([current, ...names])].filter(Boolean)
|
||||
})
|
||||
|
||||
function resetSpielerDraftState() {
|
||||
moveTargetBySpielerId.value = {}
|
||||
pendingSpielerNamesByTeamIndex.value = {}
|
||||
}
|
||||
|
||||
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) {
|
||||
return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||
}
|
||||
const existing = mannschaften.value[teamIndex]
|
||||
const list = existing ? getSpielerListe(existing) : []
|
||||
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]
|
||||
return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||
}
|
||||
|
||||
const loadMannschaften = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const csv = await fetchCsvText('/api/mannschaften')
|
||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
if (lines.length < 2) {
|
||||
mannschaften.value = []
|
||||
return
|
||||
}
|
||||
|
||||
mannschaften.value = lines.slice(1).map(line => {
|
||||
// CSV-Parser: Respektiert Anführungszeichen
|
||||
const values = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim())
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
values.push(current.trim())
|
||||
|
||||
if (values.length < 10) return null
|
||||
|
||||
return {
|
||||
mannschaft: values[0]?.trim() || '',
|
||||
liga: values[1]?.trim() || '',
|
||||
staffelleiter: values[2]?.trim() || '',
|
||||
telefon: values[3]?.trim() || '',
|
||||
heimspieltag: values[4]?.trim() || '',
|
||||
spielsystem: values[5]?.trim() || '',
|
||||
mannschaftsfuehrer: values[6]?.trim() || '',
|
||||
spieler: values[7]?.trim() || '',
|
||||
weitere_informationen_link: values[8]?.trim() || '',
|
||||
letzte_aktualisierung: values[9]?.trim() || ''
|
||||
}
|
||||
}).filter(mannschaft => mannschaft !== null && mannschaft.mannschaft !== '')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Mannschaften:', error)
|
||||
errorMessage.value = 'Fehler beim Laden der Mannschaften'
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSpielerListe = (mannschaft) => {
|
||||
if (!mannschaft.spieler) return []
|
||||
return mannschaft.spieler.split(';').map(s => s.trim()).filter(s => s !== '')
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
formData.value = {
|
||||
mannschaft: '',
|
||||
liga: '',
|
||||
staffelleiter: '',
|
||||
telefon: '',
|
||||
heimspieltag: '',
|
||||
spielsystem: '',
|
||||
mannschaftsfuehrer: '',
|
||||
spielerListe: [],
|
||||
weitere_informationen_link: '',
|
||||
letzte_aktualisierung: nowIsoDate()
|
||||
}
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
isEditing.value = false
|
||||
editingIndex.value = -1
|
||||
resetSpielerDraftState()
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
errorMessage.value = ''
|
||||
isEditing.value = false
|
||||
editingIndex.value = -1
|
||||
resetSpielerDraftState()
|
||||
}
|
||||
|
||||
const openEditModal = (mannschaft, index) => {
|
||||
formData.value = {
|
||||
mannschaft: mannschaft.mannschaft || '',
|
||||
liga: mannschaft.liga || '',
|
||||
staffelleiter: mannschaft.staffelleiter || '',
|
||||
telefon: mannschaft.telefon || '',
|
||||
heimspieltag: mannschaft.heimspieltag || '',
|
||||
spielsystem: mannschaft.spielsystem || '',
|
||||
mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '',
|
||||
spielerListe: parseSpielerString(mannschaft.spieler || ''),
|
||||
weitere_informationen_link: mannschaft.weitere_informationen_link || '',
|
||||
letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate()
|
||||
}
|
||||
isEditing.value = true
|
||||
editingIndex.value = index
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
resetSpielerDraftState()
|
||||
// Pro Spieler: aktuelle Mannschaft vorauswählen
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
for (const s of formData.value.spielerListe) {
|
||||
moveTargetBySpielerId.value[s.id] = currentTeam
|
||||
}
|
||||
}
|
||||
|
||||
const addSpieler = () => {
|
||||
const item = newSpielerItem('')
|
||||
formData.value.spielerListe.push(item)
|
||||
moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim()
|
||||
}
|
||||
|
||||
const removeSpieler = (spielerId) => {
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId)
|
||||
if (idx === -1) return
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
if (moveTargetBySpielerId.value[spielerId]) {
|
||||
delete moveTargetBySpielerId.value[spielerId]
|
||||
}
|
||||
}
|
||||
|
||||
const moveSpielerUp = (index) => {
|
||||
if (index <= 0) return
|
||||
const arr = formData.value.spielerListe
|
||||
const item = arr[index]
|
||||
arr.splice(index, 1)
|
||||
arr.splice(index - 1, 0, item)
|
||||
}
|
||||
|
||||
const moveSpielerDown = (index) => {
|
||||
const arr = formData.value.spielerListe
|
||||
if (index < 0 || index >= arr.length - 1) return
|
||||
const item = arr[index]
|
||||
arr.splice(index, 1)
|
||||
arr.splice(index + 1, 0, item)
|
||||
}
|
||||
|
||||
const canMoveSpieler = (spielerId) => {
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim()
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
return Boolean(targetName) && Boolean(currentTeam) && targetName !== currentTeam
|
||||
}
|
||||
|
||||
const moveSpielerToMannschaft = (spielerId) => {
|
||||
if (!isEditing.value || editingIndex.value < 0) return
|
||||
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim()
|
||||
if (!targetName) return
|
||||
|
||||
const targetIndex = mannschaften.value.findIndex((m, idx) => {
|
||||
if (idx === editingIndex.value) return false
|
||||
return (m?.mannschaft || '').trim() === targetName
|
||||
})
|
||||
|
||||
if (targetIndex === -1) {
|
||||
errorMessage.value = 'Ziel-Mannschaft nicht gefunden. Bitte aus der Liste auswählen.'
|
||||
return
|
||||
}
|
||||
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId)
|
||||
if (idx === -1) return
|
||||
|
||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim()
|
||||
if (!spielerName) {
|
||||
errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'
|
||||
return
|
||||
}
|
||||
|
||||
// Entfernen aus aktueller Mannschaft
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
|
||||
// Hinzufügen zur Ziel-Mannschaft (pending; wird erst beim Speichern geschrieben)
|
||||
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex)
|
||||
pendingList.push(spielerName)
|
||||
|
||||
// UI zurücksetzen
|
||||
delete moveTargetBySpielerId.value[spielerId]
|
||||
}
|
||||
|
||||
const saveMannschaft = async () => {
|
||||
isSaving.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
||||
const updated = {
|
||||
mannschaft: formData.value.mannschaft || '',
|
||||
liga: formData.value.liga || '',
|
||||
staffelleiter: formData.value.staffelleiter || '',
|
||||
telefon: formData.value.telefon || '',
|
||||
heimspieltag: formData.value.heimspieltag || '',
|
||||
spielsystem: formData.value.spielsystem || '',
|
||||
mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '',
|
||||
spieler: spielerString,
|
||||
weitere_informationen_link: formData.value.weitere_informationen_link || '',
|
||||
letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate()
|
||||
}
|
||||
|
||||
if (isEditing.value && editingIndex.value >= 0) {
|
||||
// Aktualisiere bestehende Mannschaft
|
||||
mannschaften.value[editingIndex.value] = { ...updated }
|
||||
} else {
|
||||
// Füge neue Mannschaft hinzu
|
||||
mannschaften.value.push({ ...updated })
|
||||
}
|
||||
|
||||
// Pending-Verschiebungen anwenden (andere Mannschaften)
|
||||
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
|
||||
if (touchedTeamIndexes.length > 0) {
|
||||
const ts = nowIsoDate()
|
||||
for (const idxStr of touchedTeamIndexes) {
|
||||
const idx = Number(idxStr)
|
||||
if (!Number.isFinite(idx)) continue
|
||||
const existing = mannschaften.value[idx]
|
||||
if (!existing) continue
|
||||
const pendingNames = pendingSpielerNamesByTeamIndex.value[idx]
|
||||
mannschaften.value[idx] = {
|
||||
...existing,
|
||||
spieler: serializeSpielerNames(pendingNames),
|
||||
letzte_aktualisierung: ts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Speichere als CSV
|
||||
await saveCSV()
|
||||
|
||||
closeModal()
|
||||
await loadMannschaften()
|
||||
|
||||
if (window.showSuccessModal) {
|
||||
window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern:', error)
|
||||
errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern der Mannschaft.'
|
||||
if (window.showErrorModal) {
|
||||
window.showErrorModal('Fehler', errorMessage.value)
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveCSV = async () => {
|
||||
// CSV-Header
|
||||
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
|
||||
|
||||
// CSV-Zeilen generieren
|
||||
const rows = mannschaften.value.map(m => {
|
||||
// Escape-Werte, die Kommas oder Anführungszeichen enthalten
|
||||
const escapeCSV = (value) => {
|
||||
if (!value) return ''
|
||||
const str = String(value)
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
return [
|
||||
escapeCSV(m.mannschaft),
|
||||
escapeCSV(m.liga),
|
||||
escapeCSV(m.staffelleiter),
|
||||
escapeCSV(m.telefon),
|
||||
escapeCSV(m.heimspieltag),
|
||||
escapeCSV(m.spielsystem),
|
||||
escapeCSV(m.mannschaftsfuehrer),
|
||||
escapeCSV(m.spieler),
|
||||
escapeCSV(m.weitere_informationen_link),
|
||||
escapeCSV(m.letzte_aktualisierung)
|
||||
].join(',')
|
||||
})
|
||||
|
||||
const csvContent = [header, ...rows].join('\n')
|
||||
|
||||
// Speichere über API
|
||||
await $fetch('/api/cms/save-csv', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
filename: 'mannschaften.csv',
|
||||
content: csvContent
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDelete = (mannschaft, index) => {
|
||||
if (window.showConfirmModal) {
|
||||
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
|
||||
try {
|
||||
mannschaften.value.splice(index, 1)
|
||||
await saveCSV()
|
||||
await loadMannschaften()
|
||||
window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen:', error)
|
||||
window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Fallback ohne Modal
|
||||
if (confirm(`Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) {
|
||||
mannschaften.value.splice(index, 1)
|
||||
saveCSV()
|
||||
loadMannschaften()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMannschaften().catch(() => {})
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Mannschaften verwalten - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -1,682 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-full bg-gray-50">
|
||||
<!-- Fixed Header below navigation -->
|
||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
||||
Spielpläne bearbeiten
|
||||
</h1>
|
||||
<div class="space-x-3">
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
|
||||
@click="showUploadModal = true"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
CSV hochladen
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||
@click="save"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content with top padding -->
|
||||
<div class="pt-20 pb-16">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- CSV Upload Section -->
|
||||
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
Vereins-Spielplan (CSV)
|
||||
</h2>
|
||||
|
||||
<!-- Current File Info -->
|
||||
<div
|
||||
v-if="currentFile"
|
||||
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="w-5 h-5 text-green-600 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
{{ currentFile.name }}
|
||||
</p>
|
||||
<p class="text-xs text-green-600">
|
||||
{{ currentFile.size }} bytes, {{ currentFile.lastModified ? new Date(currentFile.lastModified).toLocaleDateString('de-DE') : 'Unbekannt' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
||||
@click="removeFile"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Area -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-900 mb-2">
|
||||
CSV-Datei hochladen
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Unterstützte Formate: .csv
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Column Selection -->
|
||||
<div
|
||||
v-if="csvData.length > 0 && !columnsSelected"
|
||||
class="bg-white rounded-xl shadow-lg p-6 mb-8"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">
|
||||
Spalten auswählen
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(header, index) in csvHeaders"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
:id="`column-${index}`"
|
||||
v-model="selectedColumns[index]"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
>
|
||||
<label
|
||||
:for="`column-${index}`"
|
||||
class="ml-3 text-sm font-medium text-gray-900"
|
||||
>
|
||||
{{ header }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ getColumnPreview(index) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
|
||||
</div>
|
||||
<div class="space-x-3">
|
||||
<button
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
||||
@click="selectAllColumns"
|
||||
>
|
||||
Alle auswählen
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
|
||||
@click="deselectAllColumns"
|
||||
>
|
||||
Alle abwählen
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
|
||||
@click="suggestHalleColumns"
|
||||
>
|
||||
Halle-Spalten vorschlagen
|
||||
</button>
|
||||
<button
|
||||
:disabled="selectedColumnsCount === 0"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||
@click="confirmColumnSelection"
|
||||
>
|
||||
Auswahl bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Preview -->
|
||||
<div
|
||||
v-if="csvData.length > 0 && columnsSelected"
|
||||
class="bg-white rounded-xl shadow-lg p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Datenvorschau
|
||||
</h2>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
|
||||
@click="exportCSV"
|
||||
>
|
||||
CSV exportieren
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
|
||||
@click="clearData"
|
||||
>
|
||||
Daten löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
|
||||
:key="index"
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{{ header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
|
||||
:key="rowIndex"
|
||||
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
|
||||
>
|
||||
<td
|
||||
v-for="(cell, cellIndex) in row"
|
||||
:key="cellIndex"
|
||||
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
|
||||
>
|
||||
{{ cell }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
|
||||
class="mt-4 text-center text-sm text-gray-600"
|
||||
>
|
||||
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-600">
|
||||
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p>
|
||||
<p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else
|
||||
class="text-center py-12 bg-white rounded-xl shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-gray-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-600">
|
||||
Keine CSV-Daten geladen.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div
|
||||
v-if="showUploadModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeUploadModal"
|
||||
>
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
CSV-Datei hochladen
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label>
|
||||
<input
|
||||
ref="modalFileInput"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
@change="handleModalFileSelect"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedFile"
|
||||
class="p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-gray-700">
|
||||
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ selectedFile.size }} bytes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 p-4 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-blue-800 mb-2">
|
||||
Erwartetes CSV-Format:
|
||||
</h4>
|
||||
<div class="text-xs text-blue-700 space-y-1">
|
||||
<p>• Erste Zeile: Spaltenüberschriften</p>
|
||||
<p>• Spalten: Datum, Mannschaft, Gegner, Ort, Uhrzeit, etc.</p>
|
||||
<p>• Trennzeichen: Komma (,)</p>
|
||||
<p>• Text in Anführungszeichen bei Sonderzeichen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
@click="closeUploadModal"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
:disabled="!selectedFile"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
|
||||
@click="processSelectedFile"
|
||||
>
|
||||
Hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Modal -->
|
||||
<div
|
||||
v-if="isProcessing"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
>
|
||||
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Verarbeitung läuft...
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ processingMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'CMS: Spielpläne' })
|
||||
|
||||
const fileInput = ref(null)
|
||||
const modalFileInput = ref(null)
|
||||
const showUploadModal = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const processingMessage = ref('')
|
||||
const isDragOver = ref(false)
|
||||
|
||||
const currentFile = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const csvData = ref([])
|
||||
const csvHeaders = ref([])
|
||||
const selectedColumns = ref([])
|
||||
const columnsSelected = ref(false)
|
||||
const filteredCsvData = ref([])
|
||||
const filteredCsvHeaders = ref([])
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalFileSelect = (event) => {
|
||||
selectedFile.value = event.target.files[0]
|
||||
}
|
||||
|
||||
const handleFileDrop = (event) => {
|
||||
isDragOver.value = false
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && file.type === 'text/csv') {
|
||||
processFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const processFile = async (file) => {
|
||||
isProcessing.value = true
|
||||
processingMessage.value = 'Datei wird gelesen...'
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
processingMessage.value = 'CSV wird geparst...'
|
||||
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||
if (lines.length < 2) {
|
||||
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
|
||||
}
|
||||
|
||||
// CSV-Parser: Automatische Erkennung von Trennzeichen (Tab oder Semikolon)
|
||||
const parseCSVLine = (line) => {
|
||||
// Prüfe ob Tab oder Semikolon häufiger vorkommt
|
||||
const tabCount = (line.match(/\t/g) || []).length
|
||||
const semicolonCount = (line.match(/;/g) || []).length
|
||||
|
||||
const delimiter = tabCount > semicolonCount ? '\t' : ';'
|
||||
return line.split(delimiter).map(value => value.trim())
|
||||
}
|
||||
|
||||
// Header-Zeile parsen
|
||||
csvHeaders.value = parseCSVLine(lines[0])
|
||||
|
||||
// Datenzeilen parsen
|
||||
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||
|
||||
// Spaltenauswahl initialisieren (alle ausgewählt)
|
||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
||||
columnsSelected.value = false
|
||||
|
||||
// Datei-Info speichern
|
||||
currentFile.value = {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified
|
||||
}
|
||||
|
||||
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
||||
|
||||
setTimeout(() => {
|
||||
isProcessing.value = false
|
||||
showUploadModal.value = false
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten der CSV-Datei:', error)
|
||||
alert('Fehler beim Verarbeiten der CSV-Datei: ' + error.message)
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const processSelectedFile = () => {
|
||||
if (selectedFile.value) {
|
||||
processFile(selectedFile.value)
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = () => {
|
||||
currentFile.value = null
|
||||
csvData.value = []
|
||||
csvHeaders.value = []
|
||||
selectedColumns.value = []
|
||||
columnsSelected.value = false
|
||||
filteredCsvData.value = []
|
||||
filteredCsvHeaders.value = []
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties for column selection
|
||||
const selectedColumnsCount = computed(() => {
|
||||
return selectedColumns.value.filter(selected => selected).length
|
||||
})
|
||||
|
||||
const getColumnPreview = (index) => {
|
||||
if (csvData.value.length === 0) return 'Keine Daten'
|
||||
const sampleValues = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== '')
|
||||
return sampleValues.length > 0 ? `Beispiel: ${sampleValues.join(', ')}` : 'Leere Spalte'
|
||||
}
|
||||
|
||||
const selectAllColumns = () => {
|
||||
selectedColumns.value = selectedColumns.value.map(() => true)
|
||||
}
|
||||
|
||||
const deselectAllColumns = () => {
|
||||
selectedColumns.value = selectedColumns.value.map(() => false)
|
||||
}
|
||||
|
||||
const confirmColumnSelection = () => {
|
||||
// Filtere Daten basierend auf ausgewählten Spalten
|
||||
const selectedIndices = selectedColumns.value.map((selected, index) => selected ? index : -1).filter(index => index !== -1)
|
||||
|
||||
filteredCsvHeaders.value = selectedIndices.map(index => csvHeaders.value[index])
|
||||
filteredCsvData.value = csvData.value.map(row => selectedIndices.map(index => row[index]))
|
||||
|
||||
columnsSelected.value = true
|
||||
}
|
||||
|
||||
const suggestHalleColumns = () => {
|
||||
// Automatisch Halle-Spalten vorschlagen
|
||||
csvHeaders.value.forEach((header, index) => {
|
||||
const headerLower = header.toLowerCase()
|
||||
if (headerLower.includes('halle') ||
|
||||
headerLower.includes('strasse') ||
|
||||
headerLower.includes('plz') ||
|
||||
headerLower.includes('ort')) {
|
||||
selectedColumns.value[index] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearData = () => {
|
||||
if (confirm('Möchten Sie alle Daten wirklich löschen?')) {
|
||||
removeFile()
|
||||
}
|
||||
}
|
||||
|
||||
const exportCSV = () => {
|
||||
const dataToExport = columnsSelected.value ? filteredCsvData.value : csvData.value
|
||||
const headersToExport = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
||||
|
||||
if (dataToExport.length === 0) return
|
||||
|
||||
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
|
||||
const csvContent = [
|
||||
headersToExport.join(';'),
|
||||
...dataToExport.map(row => row.join(';'))
|
||||
].join('\n')
|
||||
|
||||
// Download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const dataToSave = columnsSelected.value ? filteredCsvData.value : csvData.value
|
||||
const headersToSave = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
||||
|
||||
if (dataToSave.length === 0) {
|
||||
alert('Keine Daten zum Speichern vorhanden.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
|
||||
const csvContent = [
|
||||
headersToSave.join(';'),
|
||||
...dataToSave.map(row => row.join(';'))
|
||||
].join('\n')
|
||||
|
||||
// CSV speichern
|
||||
const response = await fetch('/api/cms/save-csv', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: 'spielplan.csv',
|
||||
content: csvContent
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert('Spielplan erfolgreich gespeichert!')
|
||||
} else {
|
||||
alert('Fehler beim Speichern des Spielplans!')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern:', error)
|
||||
alert('Fehler beim Speichern des Spielplans!')
|
||||
}
|
||||
}
|
||||
|
||||
const closeUploadModal = () => {
|
||||
showUploadModal.value = false
|
||||
selectedFile.value = null
|
||||
if (modalFileInput.value) {
|
||||
modalFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and Drop Events
|
||||
const _handleDragEnter = () => {
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const _handleDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Load existing data if available
|
||||
loadExistingData()
|
||||
})
|
||||
|
||||
const loadExistingData = async () => {
|
||||
try {
|
||||
const response = await fetch('/data/spielplan.csv')
|
||||
if (response.ok) {
|
||||
const text = await response.text()
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||
|
||||
if (lines.length >= 2) {
|
||||
// Parse existing CSV
|
||||
const parseCSVLine = (line) => {
|
||||
const values = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim())
|
||||
current = ''
|
||||
} else {
|
||||
current += char
|
||||
}
|
||||
}
|
||||
values.push(current.trim())
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
csvHeaders.value = parseCSVLine(lines[0])
|
||||
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||
|
||||
currentFile.value = {
|
||||
name: 'spielplan.csv',
|
||||
size: text.length,
|
||||
lastModified: null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fehler beim Laden der Datei, ignorieren
|
||||
}
|
||||
}
|
||||
</script>
|
||||
58
pages/cms/sportbetrieb.vue
Normal file
58
pages/cms/sportbetrieb.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-display font-bold text-gray-900">Sportbetrieb verwalten</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Termine, Mannschaften und Spielpläne pflegen</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
:class="activeTab === tab.id
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div>
|
||||
<CmsTermine v-if="activeTab === 'termine'" />
|
||||
<CmsMannschaften v-if="activeTab === 'mannschaften'" />
|
||||
<CmsSpielplaene v-if="activeTab === 'spielplaene'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import CmsTermine from '~/components/cms/CmsTermine.vue'
|
||||
import CmsMannschaften from '~/components/cms/CmsMannschaften.vue'
|
||||
import CmsSpielplaene from '~/components/cms/CmsSpielplaene.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Sportbetrieb verwalten – CMS'
|
||||
})
|
||||
|
||||
const activeTab = ref('termine')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'termine', label: 'Termine' },
|
||||
{ id: 'mannschaften', label: 'Mannschaften' },
|
||||
{ id: 'spielplaene', label: 'Spielpläne' }
|
||||
]
|
||||
</script>
|
||||
@@ -1,391 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 bg-gray-50">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-2">
|
||||
Termine verwalten
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-4" />
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<Plus
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/>
|
||||
Termin hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2
|
||||
:size="40"
|
||||
class="animate-spin text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Termine Table -->
|
||||
<div
|
||||
v-else
|
||||
class="bg-white rounded-xl shadow-lg overflow-hidden"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Datum
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Uhrzeit
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Titel
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Beschreibung
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Kategorie
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="termin in termine"
|
||||
:key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ formatDate(termin.datum) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ termin.uhrzeit || '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||
{{ termin.titel }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ termin.beschreibung || '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800': termin.kategorie === 'Training',
|
||||
'bg-green-100 text-green-800': termin.kategorie === 'Punktspiel',
|
||||
'bg-purple-100 text-purple-800': termin.kategorie === 'Turnier',
|
||||
'bg-yellow-100 text-yellow-800': termin.kategorie === 'Veranstaltung',
|
||||
'bg-gray-100 text-gray-800': termin.kategorie === 'Sonstiges'
|
||||
}"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ termin.kategorie }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button
|
||||
class="text-gray-600 hover:text-gray-900"
|
||||
title="Bearbeiten"
|
||||
@click="openEditModal(termin)"
|
||||
>
|
||||
<Pencil :size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="text-red-600 hover:text-red-900"
|
||||
title="Löschen"
|
||||
@click="confirmDelete(termin)"
|
||||
>
|
||||
<Trash2 :size="18" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="termine.length === 0"
|
||||
class="text-center py-12 text-gray-500"
|
||||
>
|
||||
Keine Termine vorhanden.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
|
||||
{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="saveTermin"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
|
||||
<input
|
||||
v-model="formData.datum"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
|
||||
<input
|
||||
v-model="formData.uhrzeit"
|
||||
type="time"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
|
||||
<select
|
||||
v-model="formData.kategorie"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<option value="Training">
|
||||
Training
|
||||
</option>
|
||||
<option value="Punktspiel">
|
||||
Punktspiel
|
||||
</option>
|
||||
<option value="Turnier">
|
||||
Turnier
|
||||
</option>
|
||||
<option value="Veranstaltung">
|
||||
Veranstaltung
|
||||
</option>
|
||||
<option value="Sonstiges">
|
||||
Sonstiges
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
|
||||
<input
|
||||
v-model="formData.titel"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea
|
||||
v-model="formData.beschreibung"
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||
>
|
||||
<AlertCircle
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
:disabled="isSaving"
|
||||
@click="closeModal"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<Loader2
|
||||
v-if="isSaving"
|
||||
:size="20"
|
||||
class="animate-spin mr-2"
|
||||
/>
|
||||
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-vue-next'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const termine = ref([])
|
||||
const showModal = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isEditing = ref(false)
|
||||
const originalTermin = ref(null)
|
||||
|
||||
const formData = ref({
|
||||
datum: '',
|
||||
titel: '',
|
||||
beschreibung: '',
|
||||
kategorie: 'Sonstiges'
|
||||
})
|
||||
|
||||
const loadTermine = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/termine-manage')
|
||||
termine.value = response.termine
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Termine:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
formData.value = {
|
||||
datum: '',
|
||||
titel: '',
|
||||
beschreibung: '',
|
||||
kategorie: 'Sonstiges',
|
||||
uhrzeit: ''
|
||||
}
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
isEditing.value = false
|
||||
originalTermin.value = null
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
errorMessage.value = ''
|
||||
isEditing.value = false
|
||||
originalTermin.value = null
|
||||
}
|
||||
|
||||
const saveTermin = async () => {
|
||||
isSaving.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
if (isEditing.value && originalTermin.value) {
|
||||
const params = new URLSearchParams({
|
||||
datum: originalTermin.value.datum,
|
||||
uhrzeit: originalTermin.value.uhrzeit || '',
|
||||
titel: originalTermin.value.titel,
|
||||
beschreibung: originalTermin.value.beschreibung || '',
|
||||
kategorie: originalTermin.value.kategorie || 'Sonstiges'
|
||||
})
|
||||
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
await $fetch('/api/termine-manage', {
|
||||
method: 'POST',
|
||||
body: formData.value
|
||||
})
|
||||
|
||||
closeModal()
|
||||
await loadTermine()
|
||||
} catch (error) {
|
||||
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Termins.'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (termin) => {
|
||||
formData.value = {
|
||||
datum: termin.datum || '',
|
||||
uhrzeit: termin.uhrzeit || '',
|
||||
titel: termin.titel || '',
|
||||
beschreibung: termin.beschreibung || '',
|
||||
kategorie: termin.kategorie || 'Sonstiges'
|
||||
}
|
||||
originalTermin.value = { ...termin }
|
||||
isEditing.value = true
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const confirmDelete = async (termin) => {
|
||||
window.showConfirmModal('Termin löschen', `Möchten Sie den Termin "${termin.titel}" wirklich löschen?`, async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
datum: termin.datum,
|
||||
uhrzeit: termin.uhrzeit || '',
|
||||
titel: termin.titel,
|
||||
beschreibung: termin.beschreibung || '',
|
||||
kategorie: termin.kategorie || 'Sonstiges'
|
||||
})
|
||||
|
||||
await $fetch(`/api/termine-manage?${params.toString()}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
await loadTermine()
|
||||
window.showSuccessModal('Erfolg', 'Termin wurde erfolgreich gelöscht')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen:', error)
|
||||
window.showErrorModal('Fehler', 'Fehler beim Löschen des Termins')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTermine()
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Termine verwalten - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-full bg-gray-50">
|
||||
<!-- Fixed Header below navigation -->
|
||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
||||
TT-Regeln bearbeiten
|
||||
</h1>
|
||||
<div class="space-x-3">
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||
@click="save"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Toolbar below header -->
|
||||
<div
|
||||
class="fixed left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm"
|
||||
style="top: 9.5rem;"
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
|
||||
<!-- Formatierung -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('bold')"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('italic')"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(1)"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(2)"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(3)"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Listen -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertUnorderedList')"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertOrderedList')"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Schnellzugriff für Regeln -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
|
||||
@click="insertRuleTemplate('generic')"
|
||||
>
|
||||
Neue Regel
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
|
||||
@click="insertRuleTemplate('basic')"
|
||||
>
|
||||
Grundregel
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
|
||||
@click="insertRuleTemplate('penalty')"
|
||||
>
|
||||
Strafregel
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
|
||||
@click="insertRuleTemplate('service')"
|
||||
>
|
||||
Aufschlag
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
|
||||
@click="deleteCurrentRule()"
|
||||
>
|
||||
Regel löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Tools -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="createLink()"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="removeFormat()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content with top padding -->
|
||||
<div
|
||||
class="pb-16"
|
||||
style="padding-top: 12rem;"
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Hilfe-Sektion -->
|
||||
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold text-blue-900 mb-2">
|
||||
💡 So arbeiten Sie mit Regel-Kästchen:
|
||||
</h3>
|
||||
<div class="text-sm text-blue-800 space-y-2">
|
||||
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li>• <span class="bg-gray-100 px-2 py-1 rounded text-xs">Neue Regel</span> - Graues Kästchen</li>
|
||||
<li>• <span class="bg-blue-100 px-2 py-1 rounded text-xs">Grundregel</span> - Blaues Kästchen</li>
|
||||
<li>• <span class="bg-green-100 px-2 py-1 rounded text-xs">Strafregel</span> - Grünes Kästchen</li>
|
||||
<li>• <span class="bg-yellow-100 px-2 py-1 rounded text-xs">Aufschlag</span> - Gelbes Kästchen</li>
|
||||
</ul>
|
||||
<p><strong>2. Kästchen löschen:</strong> Klicken Sie in ein Kästchen und dann auf <span class="bg-red-100 px-2 py-1 rounded text-xs">Regel löschen</span></p>
|
||||
<p><strong>3. Kästchen bearbeiten:</strong> Klicken Sie direkt in die Texte und bearbeiten Sie sie</p>
|
||||
<p><strong>4. Grid-Layout:</strong> Kästchen werden automatisch im Grid-Layout angeordnet</p>
|
||||
<p class="text-xs text-blue-600 mt-2">
|
||||
💡 <strong>Tipp:</strong> Neue Kästchen werden automatisch in das bestehende Grid eingefügt!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div
|
||||
ref="editor"
|
||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||
contenteditable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'CMS: TT-Regeln' })
|
||||
|
||||
const editor = ref(null)
|
||||
const initialHtml = ref('')
|
||||
|
||||
async function load() {
|
||||
const data = await $fetch('/api/config')
|
||||
initialHtml.value = data?.seiten?.ttRegeln || ''
|
||||
if (editor.value) editor.value.innerHTML = initialHtml.value
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const html = editor.value?.innerHTML || ''
|
||||
const current = await $fetch('/api/config')
|
||||
const updated = { ...current, seiten: { ...(current.seiten || {}), ttRegeln: html } }
|
||||
try {
|
||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch {
|
||||
// Modal nicht verfügbar, ignorieren
|
||||
}
|
||||
} catch (error) {
|
||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
||||
// Modal nicht verfügbar, ignorieren
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function format(cmd) {
|
||||
document.execCommand(cmd, false, null)
|
||||
}
|
||||
|
||||
function formatHeader(level) {
|
||||
document.execCommand('formatBlock', false, 'H' + level)
|
||||
}
|
||||
|
||||
function createLink() {
|
||||
const url = prompt('URL eingeben:')
|
||||
if (!url) return
|
||||
document.execCommand('createLink', false, url)
|
||||
}
|
||||
|
||||
function removeFormat() {
|
||||
document.execCommand('removeFormat', false, null)
|
||||
}
|
||||
|
||||
function insertRuleTemplate(type) {
|
||||
const editorElement = editor.value
|
||||
if (!editorElement) return
|
||||
|
||||
let template = ''
|
||||
let bgColor = 'bg-gray-50'
|
||||
|
||||
switch (type) {
|
||||
case 'generic':
|
||||
template = `
|
||||
<div class="text-center p-6 bg-gray-50 rounded-lg">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Regel</h3>
|
||||
<p class="text-gray-600 text-sm">[Regeltext hier eingeben]</p>
|
||||
</div>
|
||||
`
|
||||
bgColor = 'bg-gray-50'
|
||||
break
|
||||
case 'basic':
|
||||
template = `
|
||||
<div class="text-center p-6 bg-blue-50 rounded-lg">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Grundregel</h3>
|
||||
<p class="text-gray-600 text-sm"><strong>Regel:</strong> [Regeltext hier eingeben]<br><strong>Beschreibung:</strong> [Detaillierte Beschreibung hier eingeben]<br><strong>Anwendung:</strong> [Wann gilt diese Regel?]</p>
|
||||
</div>
|
||||
`
|
||||
bgColor = 'bg-blue-50'
|
||||
break
|
||||
case 'penalty':
|
||||
template = `
|
||||
<div class="text-center p-6 bg-green-50 rounded-lg">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Strafregel</h3>
|
||||
<p class="text-gray-600 text-sm"><strong>Verstoß:</strong> [Was ist der Verstoß?]<br><strong>Strafe:</strong> [Welche Strafe wird verhängt?]<br><strong>Häufigkeit:</strong> [Bei wiederholten Verstößen?]</p>
|
||||
</div>
|
||||
`
|
||||
// bgColor = 'bg-green-50' // Nicht verwendet
|
||||
break
|
||||
case 'service':
|
||||
template = `
|
||||
<div class="text-center p-6 bg-yellow-50 rounded-lg">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Aufschlagregel</h3>
|
||||
<p class="text-gray-600 text-sm"><strong>Regel:</strong> [Aufschlagregel hier eingeben]<br><strong>Technik:</strong> [Wie muss der Aufschlag ausgeführt werden?]<br><strong>Fehler:</strong> [Was gilt als Fehler?]</p>
|
||||
</div>
|
||||
`
|
||||
// bgColor = 'bg-yellow-50'
|
||||
break
|
||||
}
|
||||
|
||||
// Editor fokussieren
|
||||
editorElement.focus()
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
// Prüfen ob der Cursor im Editor ist
|
||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||
// Aktuelles Element finden
|
||||
let currentElement = range.commonAncestorContainer
|
||||
|
||||
// Falls es ein Text-Node ist, zum Parent-Element gehen
|
||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
||||
currentElement = currentElement.parentElement
|
||||
}
|
||||
|
||||
// Zum spezifischen Container navigieren mit den Klassen "grid md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
let targetContainer = currentElement
|
||||
while (targetContainer && !targetContainer.classList.contains('grid')) {
|
||||
targetContainer = targetContainer.parentElement
|
||||
}
|
||||
|
||||
// Prüfen ob es der richtige Container ist
|
||||
if (targetContainer &&
|
||||
targetContainer.classList.contains('md:grid-cols-2') &&
|
||||
targetContainer.classList.contains('lg:grid-cols-3') &&
|
||||
targetContainer.classList.contains('gap-6')) {
|
||||
|
||||
// Wir sind im richtigen Container - neues Kästchen hinzufügen
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = template
|
||||
|
||||
// Neues Kästchen in den Container einfügen
|
||||
// Suche nach dem ersten Element-Node (nicht Text-Node)
|
||||
let newCard = null
|
||||
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
||||
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) {
|
||||
newCard = tempDiv.childNodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (newCard) {
|
||||
targetContainer.appendChild(newCard)
|
||||
|
||||
// Cursor in das neue Kästchen setzen
|
||||
const newRange = document.createRange()
|
||||
const titleElement = newCard.querySelector('h3')
|
||||
if (titleElement) {
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(newRange)
|
||||
}
|
||||
} else {
|
||||
console.error('No valid element found in template');
|
||||
}
|
||||
} else {
|
||||
// Spezifischer Container nicht gefunden - am Ende einfügen
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
} else {
|
||||
// Cursor ist nicht im Editor - Template am Ende einfügen
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
} else {
|
||||
// Keine Auswahl - Template am Ende einfügen
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCurrentRule() {
|
||||
const editorElement = editor.value
|
||||
if (!editorElement) return
|
||||
|
||||
// Editor fokussieren
|
||||
editorElement.focus()
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
// Prüfen ob der Cursor im Editor ist
|
||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||
// Aktuelles Element finden
|
||||
let currentElement = range.commonAncestorContainer
|
||||
|
||||
// Falls es ein Text-Node ist, zum Parent-Element gehen
|
||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
||||
currentElement = currentElement.parentElement
|
||||
}
|
||||
|
||||
// Zum Grid-Kästchen navigieren
|
||||
let cardElement = currentElement
|
||||
while (cardElement && !cardElement.classList.contains('text-center')) {
|
||||
cardElement = cardElement.parentElement
|
||||
}
|
||||
|
||||
if (cardElement && cardElement.classList.contains('text-center')) {
|
||||
// Grid-Kästchen gefunden - löschen
|
||||
cardElement.remove()
|
||||
|
||||
// Cursor in das nächste Kästchen oder Grid setzen
|
||||
const gridContainer = editorElement.querySelector('.grid')
|
||||
if (gridContainer && gridContainer.children.length > 0) {
|
||||
const firstCard = gridContainer.firstElementChild
|
||||
const titleElement = firstCard.querySelector('h3')
|
||||
if (titleElement) {
|
||||
const newRange = document.createRange()
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(newRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,154 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-full bg-gray-50">
|
||||
<!-- Fixed Header below navigation -->
|
||||
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">
|
||||
Über uns bearbeiten
|
||||
</h1>
|
||||
<div class="space-x-3">
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||
@click="save"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Toolbar below header -->
|
||||
<div class="fixed top-[9.5rem] left-0 right-0 z-30 bg-white border-b border-gray-200 shadow-sm">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('bold')"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('italic')"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(1)"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(2)"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(3)"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertUnorderedList')"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertOrderedList')"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="createLink()"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="removeFormat()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content with top padding -->
|
||||
<div class="pt-36 sm:pt-44 pb-16">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div
|
||||
ref="editor"
|
||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||
contenteditable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({ title: 'CMS: Über uns' })
|
||||
|
||||
const editor = ref(null)
|
||||
const initialHtml = ref('')
|
||||
|
||||
async function load() {
|
||||
const data = await $fetch('/api/config')
|
||||
initialHtml.value = data?.seiten?.ueberUns || ''
|
||||
if (editor.value) editor.value.innerHTML = initialHtml.value
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const html = editor.value?.innerHTML || ''
|
||||
const current = await $fetch('/api/config')
|
||||
const updated = { ...current, seiten: { ...(current.seiten || {}), ueberUns: html } }
|
||||
try {
|
||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
|
||||
// Modal nicht verfügbar, ignorieren
|
||||
}
|
||||
} catch (error) {
|
||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
||||
// Modal nicht verfügbar, ignorieren
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function format(cmd) {
|
||||
document.execCommand(cmd, false, null)
|
||||
}
|
||||
|
||||
function formatHeader(level) {
|
||||
document.execCommand('formatBlock', false, 'H' + level)
|
||||
}
|
||||
|
||||
function createLink() {
|
||||
const url = prompt('URL eingeben:')
|
||||
if (!url) return
|
||||
document.execCommand('createLink', false, url)
|
||||
}
|
||||
|
||||
function removeFormat() {
|
||||
document.execCommand('removeFormat', false, null)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user