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:
@@ -336,32 +336,11 @@
|
||||
</NuxtLink>
|
||||
<div class="border-t border-gray-700 my-1" />
|
||||
<NuxtLink
|
||||
to="/cms/ueber-uns"
|
||||
to="/cms/inhalte"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
Über uns
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/geschichte"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
Geschichte
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/tt-regeln"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
TT-Regeln
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/satzung"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
Satzung
|
||||
Inhalte
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/vereinsmeisterschaften"
|
||||
@@ -379,25 +358,11 @@
|
||||
News
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/termine"
|
||||
to="/cms/sportbetrieb"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
Termine
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/mannschaften"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
Mannschaften
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/spielplaene"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
Spielpläne
|
||||
Sportbetrieb
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/mitglieder"
|
||||
@@ -773,25 +738,11 @@
|
||||
News
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/termine"
|
||||
to="/cms/sportbetrieb"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Termine
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/mannschaften"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Mannschaften
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/spielplaene"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Spielpläne
|
||||
Sportbetrieb
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/mitglieder"
|
||||
@@ -801,32 +752,11 @@
|
||||
Mitglieder
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/ueber-uns"
|
||||
to="/cms/inhalte"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Über uns
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/geschichte"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Geschichte
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/tt-regeln"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
TT-Regeln
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/satzung"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Satzung
|
||||
Inhalte
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/vereinsmeisterschaften"
|
||||
|
||||
380
components/cms/CmsGeschichte.vue
Normal file
380
components/cms/CmsGeschichte.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header with save button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||
Geschichte bearbeiten
|
||||
</h2>
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||
@click="save"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||
<!-- Formatierung -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('bold')"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('italic')"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(1)"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(2)"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(3)"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Listen -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertUnorderedList')"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertOrderedList')"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Schnellzugriff für Geschichts-Abschnitte -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
|
||||
@click="insertHistoryTemplate('generic')"
|
||||
>
|
||||
Neuer Abschnitt
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
|
||||
@click="insertHistoryTemplate('founding')"
|
||||
>
|
||||
Gründung
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
|
||||
@click="insertHistoryTemplate('milestone')"
|
||||
>
|
||||
Meilenstein
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
|
||||
@click="insertHistoryTemplate('achievement')"
|
||||
>
|
||||
Erfolg
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
|
||||
@click="deleteCurrentSection()"
|
||||
>
|
||||
Abschnitt löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weitere Tools -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="createLink()"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="removeFormat()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="bg-white rounded-b-lg shadow-sm border border-t-0 border-gray-200 p-3 sm:p-4">
|
||||
<div
|
||||
ref="editor"
|
||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||
contenteditable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const editor = ref(null)
|
||||
|
||||
async function load() {
|
||||
const data = await $fetch('/api/config')
|
||||
const html = data?.seiten?.geschichte || ''
|
||||
if (editor.value) editor.value.innerHTML = html
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const html = editor.value?.innerHTML || ''
|
||||
const current = await $fetch('/api/config')
|
||||
const updated = { ...current, seiten: { ...(current.seiten || {}), 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
|
||||
}
|
||||
} catch (error) {
|
||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
||||
// Modal nicht verfügbar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function format(cmd) {
|
||||
document.execCommand(cmd, false, null)
|
||||
}
|
||||
|
||||
function formatHeader(level) {
|
||||
document.execCommand('formatBlock', false, 'H' + level)
|
||||
}
|
||||
|
||||
function createLink() {
|
||||
const url = prompt('URL eingeben:')
|
||||
if (!url) return
|
||||
document.execCommand('createLink', false, url)
|
||||
}
|
||||
|
||||
function removeFormat() {
|
||||
document.execCommand('removeFormat', false, null)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
editorElement.focus()
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||
let currentElement = range.commonAncestorContainer
|
||||
|
||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
||||
currentElement = currentElement.parentElement
|
||||
}
|
||||
|
||||
let 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')) {
|
||||
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = template
|
||||
|
||||
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 (sectionElement.nextSibling) {
|
||||
sectionElement.parentElement.insertBefore(newSection, sectionElement.nextSibling)
|
||||
} else {
|
||||
sectionElement.parentElement.appendChild(newSection)
|
||||
}
|
||||
|
||||
const newRange = document.createRange()
|
||||
const titleElement = newSection.querySelector('h3')
|
||||
if (titleElement) {
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(newRange)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let nextSection = null
|
||||
const 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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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) {
|
||||
nextSection.parentElement.insertBefore(newSection, nextSection)
|
||||
} else {
|
||||
editorElement.appendChild(newSection)
|
||||
}
|
||||
|
||||
const newRange = document.createRange()
|
||||
const titleElement = newSection.querySelector('h3')
|
||||
if (titleElement) {
|
||||
newRange.setStart(titleElement, 0)
|
||||
newRange.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(newRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
} else {
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCurrentSection() {
|
||||
const editorElement = editor.value
|
||||
if (!editorElement) return
|
||||
|
||||
editorElement.focus()
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||
let currentElement = range.commonAncestorContainer
|
||||
|
||||
if (currentElement.nodeType === Node.TEXT_NODE) {
|
||||
currentElement = currentElement.parentElement
|
||||
}
|
||||
|
||||
let 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')) {
|
||||
sectionElement.remove()
|
||||
|
||||
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>
|
||||
245
components/cms/CmsMannschaften.vue
Normal file
245
components/cms/CmsMannschaften.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2>
|
||||
<div class="w-24 h-1 bg-primary-600" />
|
||||
</div>
|
||||
<button class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">
|
||||
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div>
|
||||
|
||||
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaft</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button>
|
||||
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center">
|
||||
<Users :size="48" class="text-gray-400 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3>
|
||||
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p>
|
||||
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2>
|
||||
</div>
|
||||
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
|
||||
<input v-model="formData.mannschaft" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
|
||||
<input v-model="formData.liga" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
|
||||
<input v-model="formData.staffelleiter" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
|
||||
<input v-model="formData.telefon" type="tel" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
|
||||
<input v-model="formData.heimspieltag" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
|
||||
<input v-model="formData.spielsystem" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
|
||||
<input v-model="formData.mannschaftsfuehrer" type="text" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
|
||||
<div class="space-y-2">
|
||||
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div>
|
||||
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center gap-2">
|
||||
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving">
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button>
|
||||
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button>
|
||||
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen">
|
||||
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button>
|
||||
<p class="text-xs text-gray-500">Reihenfolge per ↑/↓ ändern.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
|
||||
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving">
|
||||
</div>
|
||||
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"><AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}</div>
|
||||
<div class="flex justify-end space-x-4 pt-4">
|
||||
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
|
||||
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving"><Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const mannschaften = ref([])
|
||||
const showModal = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isEditing = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
|
||||
const moveTargetBySpielerId = ref({})
|
||||
const pendingSpielerNamesByTeamIndex = ref({})
|
||||
|
||||
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
|
||||
function newSpielerItem(name = '') { return { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, name } }
|
||||
function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) }
|
||||
function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') }
|
||||
function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') }
|
||||
|
||||
async function fetchCsvText(url) {
|
||||
const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() }
|
||||
try { return await attempt() } catch { await new Promise(r => setTimeout(r, 150)); return await attempt() }
|
||||
}
|
||||
|
||||
const mannschaftenSelectOptions = computed(() => {
|
||||
const current = (formData.value.mannschaft || '').trim()
|
||||
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
|
||||
return [...new Set([current, ...names])].filter(Boolean)
|
||||
})
|
||||
|
||||
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
|
||||
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]; return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||
}
|
||||
|
||||
const loadMannschaften = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const csv = await fetchCsvText('/api/mannschaften')
|
||||
const lines = csv.split('\n').filter(line => line.trim() !== '')
|
||||
if (lines.length < 2) { mannschaften.value = []; return }
|
||||
mannschaften.value = lines.slice(1).map(line => {
|
||||
const values = []; let current = ''; let inQuotes = false
|
||||
for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }
|
||||
values.push(current.trim())
|
||||
if (values.length < 10) return null
|
||||
return { mannschaft: values[0]?.trim() || '', liga: values[1]?.trim() || '', staffelleiter: values[2]?.trim() || '', telefon: values[3]?.trim() || '', heimspieltag: values[4]?.trim() || '', spielsystem: values[5]?.trim() || '', mannschaftsfuehrer: values[6]?.trim() || '', spieler: values[7]?.trim() || '', weitere_informationen_link: values[8]?.trim() || '', letzte_aktualisierung: values[9]?.trim() || '' }
|
||||
}).filter(m => m !== null && m.mannschaft !== '')
|
||||
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
|
||||
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
|
||||
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
|
||||
const openEditModal = (mannschaft, index) => {
|
||||
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
|
||||
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
|
||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
|
||||
}
|
||||
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
|
||||
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
|
||||
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
|
||||
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
|
||||
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
|
||||
|
||||
const moveSpielerToMannschaft = (spielerId) => {
|
||||
if (!isEditing.value || editingIndex.value < 0) return
|
||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
|
||||
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
|
||||
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
|
||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
|
||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
|
||||
formData.value.spielerListe.splice(idx, 1)
|
||||
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
|
||||
delete moveTargetBySpielerId.value[spielerId]
|
||||
}
|
||||
|
||||
const saveMannschaft = async () => {
|
||||
isSaving.value = true; errorMessage.value = ''
|
||||
try {
|
||||
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
||||
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
|
||||
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
|
||||
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
|
||||
if (touchedTeamIndexes.length > 0) { const ts = nowIsoDate(); for (const idxStr of touchedTeamIndexes) { const idx = Number(idxStr); if (!Number.isFinite(idx)) continue; const existing = mannschaften.value[idx]; if (!existing) continue; mannschaften.value[idx] = { ...existing, spieler: serializeSpielerNames(pendingSpielerNamesByTeamIndex.value[idx]), letzte_aktualisierung: ts } } }
|
||||
await saveCSV(); closeModal(); await loadMannschaften()
|
||||
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
|
||||
} catch (error) { console.error('Fehler:', error); errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern.'; if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value) } finally { isSaving.value = false }
|
||||
}
|
||||
|
||||
const saveCSV = async () => {
|
||||
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
|
||||
const rows = mannschaften.value.map(m => {
|
||||
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
|
||||
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',')
|
||||
})
|
||||
await $fetch('/api/cms/save-csv', { method: 'POST', body: { filename: 'mannschaften.csv', content: [header, ...rows].join('\n') } })
|
||||
}
|
||||
|
||||
const confirmDelete = (mannschaft, index) => {
|
||||
if (window.showConfirmModal) {
|
||||
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
|
||||
try { mannschaften.value.splice(index, 1); await saveCSV(); await loadMannschaften(); window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht') } catch (error) { console.error('Fehler:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft') }
|
||||
})
|
||||
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
|
||||
}
|
||||
|
||||
onMounted(() => { loadMannschaften().catch(() => {}) })
|
||||
</script>
|
||||
278
components/cms/CmsSatzung.vue
Normal file
278
components/cms/CmsSatzung.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 1. PDF-Upload -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
PDF-Upload
|
||||
</h2>
|
||||
|
||||
<form
|
||||
enctype="multipart/form-data"
|
||||
class="space-y-4"
|
||||
@submit.prevent="uploadPdf"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="pdf-file"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Neue Satzung hochladen (PDF)
|
||||
</label>
|
||||
<input
|
||||
id="pdf-file"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Nur PDF-Dateien bis 10MB sind erlaubt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!selectedFile || uploading"
|
||||
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
v-if="uploading"
|
||||
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{{ uploading ? 'Wird hochgeladen...' : 'PDF hochladen' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2. Aktuelle PDF-Information -->
|
||||
<div
|
||||
v-if="currentPdfUrl"
|
||||
class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
Aktuelle Satzung (PDF)
|
||||
</h2>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-gray-600">
|
||||
PDF-Datei verfügbar
|
||||
</p>
|
||||
<a
|
||||
:href="currentPdfUrl"
|
||||
target="_blank"
|
||||
class="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Satzung anzeigen →
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Zuletzt aktualisiert: {{ lastUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Textfassung (WYSIWYG) -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">
|
||||
Textfassung für die Website
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
:disabled="savingText"
|
||||
@click="saveText"
|
||||
>
|
||||
<svg
|
||||
v-if="savingText"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{{ savingText ? 'Speichert...' : 'Text speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Diese HTML-Fassung wird auf der Seite „Verein → Satzung" angezeigt. Die PDF-Version bleibt die rechtlich verbindliche Fassung.
|
||||
</p>
|
||||
|
||||
<RichTextEditor
|
||||
v-model="satzungContent"
|
||||
label="Satzung (HTML-Version)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message"
|
||||
class="mt-4 p-4 rounded-lg"
|
||||
:class="messageType === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import RichTextEditor from '~/components/RichTextEditor.vue'
|
||||
|
||||
const fileInput = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const uploading = ref(false)
|
||||
const currentPdfUrl = ref('')
|
||||
const lastUpdated = ref('')
|
||||
const message = ref('')
|
||||
const messageType = ref('')
|
||||
const satzungContent = ref('')
|
||||
const savingText = ref(false)
|
||||
|
||||
async function loadCurrentSatzung() {
|
||||
try {
|
||||
const data = await $fetch('/api/config')
|
||||
const satzung = data?.seiten?.satzung
|
||||
if (satzung?.pdfUrl) {
|
||||
currentPdfUrl.value = satzung.pdfUrl
|
||||
lastUpdated.value = new Date().toLocaleDateString('de-DE')
|
||||
}
|
||||
if (satzung?.content) {
|
||||
const content = typeof satzung.content === 'string' ? satzung.content : String(satzung.content || '')
|
||||
satzungContent.value = content
|
||||
} else {
|
||||
satzungContent.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der aktuellen Satzung:', e)
|
||||
satzungContent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
message.value = 'Bitte wählen Sie eine PDF-Datei aus'
|
||||
messageType.value = 'error'
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.value = 'Die Datei ist zu groß (max. 10MB)'
|
||||
messageType.value = 'error'
|
||||
return
|
||||
}
|
||||
selectedFile.value = file
|
||||
message.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPdf() {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
uploading.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('pdf', selectedFile.value)
|
||||
|
||||
const result = await $fetch('/api/cms/satzung-upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
message.value = result.message
|
||||
messageType.value = 'success'
|
||||
|
||||
await loadCurrentSatzung()
|
||||
|
||||
selectedFile.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
|
||||
} catch (error) {
|
||||
message.value = error.data?.message || 'Fehler beim Hochladen der PDF-Datei'
|
||||
messageType.value = 'error'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveText() {
|
||||
savingText.value = true
|
||||
message.value = ''
|
||||
try {
|
||||
const current = await $fetch('/api/config')
|
||||
const currentSeiten = current.seiten || {}
|
||||
const currentSatzung = currentSeiten.satzung || {}
|
||||
|
||||
const updated = {
|
||||
...current,
|
||||
seiten: {
|
||||
...currentSeiten,
|
||||
satzung: {
|
||||
...currentSatzung,
|
||||
content: satzungContent.value || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await $fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
body: updated
|
||||
})
|
||||
|
||||
message.value = 'Satzungstext erfolgreich gespeichert'
|
||||
messageType.value = 'success'
|
||||
|
||||
try {
|
||||
window.showSuccessModal && window.showSuccessModal('Erfolg', 'Satzungstext erfolgreich gespeichert.')
|
||||
} catch {
|
||||
// Modal optional
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error?.data?.message || 'Fehler beim Speichern des Satzungstextes'
|
||||
message.value = errMsg
|
||||
messageType.value = 'error'
|
||||
try {
|
||||
window.showErrorModal && window.showErrorModal('Fehler', errMsg)
|
||||
} catch {
|
||||
// optional
|
||||
}
|
||||
} finally {
|
||||
savingText.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCurrentSatzung)
|
||||
</script>
|
||||
194
components/cms/CmsSpielplaene.vue
Normal file
194
components/cms/CmsSpielplaene.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2>
|
||||
<div class="space-x-3">
|
||||
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
CSV hochladen
|
||||
</button>
|
||||
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV Upload Section -->
|
||||
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3>
|
||||
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div>
|
||||
</div>
|
||||
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer" :class="{ 'border-primary-400 bg-primary-50': isDragOver }" @click="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop">
|
||||
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p>
|
||||
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect">
|
||||
</div>
|
||||
|
||||
<!-- Column Selection -->
|
||||
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3>
|
||||
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50">
|
||||
<div class="flex items-center">
|
||||
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded">
|
||||
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div>
|
||||
<div class="space-x-3">
|
||||
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button>
|
||||
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button>
|
||||
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button>
|
||||
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Preview -->
|
||||
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3>
|
||||
<div class="flex space-x-2">
|
||||
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button>
|
||||
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
|
||||
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div>
|
||||
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
|
||||
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<p class="text-gray-600">Keine CSV-Daten geladen.</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal">
|
||||
<div class="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3>
|
||||
<div class="space-y-4">
|
||||
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div>
|
||||
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div>
|
||||
<div class="bg-blue-50 p-4 rounded-lg"><h4 class="text-sm font-medium text-blue-800 mb-2">Erwartetes CSV-Format:</h4><div class="text-xs text-blue-700 space-y-1"><p>• Erste Zeile: Spaltenüberschriften</p><p>• Trennzeichen: Komma (,)</p></div></div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button>
|
||||
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Modal -->
|
||||
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3>
|
||||
<p class="text-sm text-gray-600">{{ processingMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const fileInput = ref(null)
|
||||
const modalFileInput = ref(null)
|
||||
const showUploadModal = ref(false)
|
||||
const isProcessing = ref(false)
|
||||
const processingMessage = ref('')
|
||||
const isDragOver = ref(false)
|
||||
const currentFile = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const csvData = ref([])
|
||||
const csvHeaders = ref([])
|
||||
const selectedColumns = ref([])
|
||||
const columnsSelected = ref(false)
|
||||
const filteredCsvData = ref([])
|
||||
const filteredCsvHeaders = ref([])
|
||||
|
||||
const triggerFileInput = () => { fileInput.value?.click() }
|
||||
const handleFileSelect = (event) => { const file = event.target.files[0]; if (file) processFile(file) }
|
||||
const handleModalFileSelect = (event) => { selectedFile.value = event.target.files[0] }
|
||||
const handleFileDrop = (event) => { isDragOver.value = false; const file = event.dataTransfer.files[0]; if (file && file.type === 'text/csv') processFile(file) }
|
||||
|
||||
const processFile = async (file) => {
|
||||
isProcessing.value = true; processingMessage.value = 'Datei wird gelesen...'
|
||||
try {
|
||||
const text = await file.text(); processingMessage.value = 'CSV wird geparst...'
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||
if (lines.length < 2) throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
|
||||
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
|
||||
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
|
||||
currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified }
|
||||
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
||||
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
|
||||
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
|
||||
}
|
||||
|
||||
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
|
||||
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
|
||||
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
|
||||
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
|
||||
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
|
||||
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
|
||||
const confirmColumnSelection = () => { const si = selectedColumns.value.map((s, i) => s ? i : -1).filter(i => i !== -1); filteredCsvHeaders.value = si.map(i => csvHeaders.value[i]); filteredCsvData.value = csvData.value.map(row => si.map(i => row[i])); columnsSelected.value = true }
|
||||
const suggestHalleColumns = () => { csvHeaders.value.forEach((header, index) => { const h = header.toLowerCase(); if (h.includes('halle') || h.includes('strasse') || h.includes('plz') || h.includes('ort')) selectedColumns.value[index] = true }) }
|
||||
const clearData = () => { if (confirm('Möchten Sie alle Daten wirklich löschen?')) removeFile() }
|
||||
|
||||
const exportCSV = () => {
|
||||
const d = columnsSelected.value ? filteredCsvData.value : csvData.value; const h = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
||||
if (d.length === 0) return
|
||||
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const d = columnsSelected.value ? filteredCsvData.value : csvData.value; const h = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
|
||||
if (d.length === 0) { alert('Keine Daten zum Speichern vorhanden.'); return }
|
||||
try {
|
||||
const csv = [h.join(';'), ...d.map(row => row.join(';'))].join('\n')
|
||||
const response = await fetch('/api/cms/save-csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'spielplan.csv', content: csv }) })
|
||||
if (response.ok) alert('Spielplan erfolgreich gespeichert!'); else alert('Fehler beim Speichern!')
|
||||
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
|
||||
}
|
||||
|
||||
const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' }
|
||||
|
||||
onMounted(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/data/spielplan.csv'); if (!response.ok) return; const text = await response.text()
|
||||
const lines = text.split('\n').filter(line => line.trim() !== ''); if (lines.length < 2) return
|
||||
const parseCSVLine = (line) => { const values = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }; values.push(current.trim()); return values }
|
||||
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||
currentFile.value = { name: 'spielplan.csv', size: text.length, lastModified: null }
|
||||
} catch { /* ignore */ }
|
||||
})()
|
||||
})
|
||||
</script>
|
||||
188
components/cms/CmsTermine.vue
Normal file
188
components/cms/CmsTermine.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
|
||||
Termine verwalten
|
||||
</h2>
|
||||
<div class="w-24 h-1 bg-primary-600" />
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<Plus :size="20" class="mr-2" />
|
||||
Termin hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
||||
<Loader2 :size="40" class="animate-spin text-primary-600" />
|
||||
</div>
|
||||
|
||||
<!-- Termine Table -->
|
||||
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800': termin.kategorie === 'Training',
|
||||
'bg-green-100 text-green-800': termin.kategorie === 'Punktspiel',
|
||||
'bg-purple-100 text-purple-800': termin.kategorie === 'Turnier',
|
||||
'bg-yellow-100 text-yellow-800': termin.kategorie === 'Veranstaltung',
|
||||
'bg-gray-100 text-gray-800': termin.kategorie === 'Sonstiges'
|
||||
}"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>{{ termin.kategorie }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
|
||||
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button>
|
||||
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" @click.self="closeModal">
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2>
|
||||
<form class="space-y-4" @submit.prevent="saveTermin">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
|
||||
<input v-model="formData.datum" type="date" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
|
||||
<input v-model="formData.uhrzeit" type="time" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
|
||||
<select v-model="formData.kategorie" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
<option value="Training">Training</option>
|
||||
<option value="Punktspiel">Punktspiel</option>
|
||||
<option value="Turnier">Turnier</option>
|
||||
<option value="Veranstaltung">Veranstaltung</option>
|
||||
<option value="Sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
|
||||
<input v-model="formData.titel" type="text" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
|
||||
<textarea v-model="formData.beschreibung" rows="3" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" :disabled="isSaving" />
|
||||
</div>
|
||||
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }}
|
||||
</div>
|
||||
<div class="flex justify-end space-x-4 pt-4">
|
||||
<button type="button" class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors" :disabled="isSaving" @click="closeModal">Abbrechen</button>
|
||||
<button type="submit" class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center" :disabled="isSaving">
|
||||
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" />
|
||||
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Plus, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-vue-next'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const termine = ref([])
|
||||
const showModal = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isEditing = ref(false)
|
||||
const originalTermin = ref(null)
|
||||
|
||||
const formData = ref({ datum: '', titel: '', beschreibung: '', kategorie: 'Sonstiges', uhrzeit: '' })
|
||||
|
||||
const loadTermine = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/termine-manage')
|
||||
termine.value = response.termine
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Termine:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
formData.value = { datum: '', titel: '', beschreibung: '', kategorie: 'Sonstiges', uhrzeit: '' }
|
||||
showModal.value = true; errorMessage.value = ''; isEditing.value = false; originalTermin.value = null
|
||||
}
|
||||
|
||||
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; originalTermin.value = null }
|
||||
|
||||
const saveTermin = async () => {
|
||||
isSaving.value = true; errorMessage.value = ''
|
||||
try {
|
||||
if (isEditing.value && originalTermin.value) {
|
||||
const params = new URLSearchParams({ datum: originalTermin.value.datum, uhrzeit: originalTermin.value.uhrzeit || '', titel: originalTermin.value.titel, beschreibung: originalTermin.value.beschreibung || '', kategorie: originalTermin.value.kategorie || 'Sonstiges' })
|
||||
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
|
||||
}
|
||||
await $fetch('/api/termine-manage', { method: 'POST', body: formData.value })
|
||||
closeModal(); await loadTermine()
|
||||
} catch (error) {
|
||||
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Termins.'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (termin) => {
|
||||
formData.value = { datum: termin.datum || '', uhrzeit: termin.uhrzeit || '', titel: termin.titel || '', beschreibung: termin.beschreibung || '', kategorie: termin.kategorie || 'Sonstiges' }
|
||||
originalTermin.value = { ...termin }; isEditing.value = true; showModal.value = true; errorMessage.value = ''
|
||||
}
|
||||
|
||||
const confirmDelete = async (termin) => {
|
||||
window.showConfirmModal('Termin löschen', `Möchten Sie den Termin "${termin.titel}" wirklich löschen?`, async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ datum: termin.datum, uhrzeit: termin.uhrzeit || '', titel: termin.titel, beschreibung: termin.beschreibung || '', kategorie: termin.kategorie || 'Sonstiges' })
|
||||
await $fetch(`/api/termine-manage?${params.toString()}`, { method: 'DELETE' })
|
||||
await loadTermine(); window.showSuccessModal('Erfolg', 'Termin wurde erfolgreich gelöscht')
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen des Termins')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => { loadTermine() })
|
||||
</script>
|
||||
183
components/cms/CmsTtRegeln.vue
Normal file
183
components/cms/CmsTtRegeln.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header with save button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||
TT-Regeln bearbeiten
|
||||
</h2>
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||
@click="save"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||
<!-- Formatierung -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button>
|
||||
</div>
|
||||
<!-- Listen -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')">•</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button>
|
||||
</div>
|
||||
<!-- Schnellzugriff für Regeln -->
|
||||
<div class="flex items-center gap-1 border-r pr-2 mr-2">
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button>
|
||||
</div>
|
||||
<!-- Weitere Tools -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button>
|
||||
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hilfe-Sektion -->
|
||||
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3>
|
||||
<div class="text-sm text-blue-800 space-y-2">
|
||||
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
|
||||
<ul class="ml-4 space-y-1">
|
||||
<li>• <span class="bg-gray-100 px-2 py-1 rounded text-xs">Neue Regel</span> - Graues Kästchen</li>
|
||||
<li>• <span class="bg-blue-100 px-2 py-1 rounded text-xs">Grundregel</span> - Blaues Kästchen</li>
|
||||
<li>• <span class="bg-green-100 px-2 py-1 rounded text-xs">Strafregel</span> - Grünes Kästchen</li>
|
||||
<li>• <span class="bg-yellow-100 px-2 py-1 rounded text-xs">Aufschlag</span> - Gelbes Kästchen</li>
|
||||
</ul>
|
||||
<p><strong>2. Kästchen löschen:</strong> Klicken Sie in ein Kästchen und dann auf <span class="bg-red-100 px-2 py-1 rounded text-xs">Regel löschen</span></p>
|
||||
<p><strong>3. Kästchen bearbeiten:</strong> Klicken Sie direkt in die Texte und bearbeiten Sie sie</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 sm:p-4">
|
||||
<div
|
||||
ref="editor"
|
||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||
contenteditable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const editor = ref(null)
|
||||
|
||||
async function load() {
|
||||
const data = await $fetch('/api/config')
|
||||
const html = data?.seiten?.ttRegeln || ''
|
||||
if (editor.value) editor.value.innerHTML = html
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const html = editor.value?.innerHTML || ''
|
||||
const current = await $fetch('/api/config')
|
||||
const updated = { ...current, seiten: { ...(current.seiten || {}), ttRegeln: html } }
|
||||
try {
|
||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Regeln erfolgreich gespeichert!') } catch { /* */ }
|
||||
} catch (error) {
|
||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch { /* */ }
|
||||
}
|
||||
}
|
||||
|
||||
function format(cmd) { document.execCommand(cmd, false, null) }
|
||||
function formatHeader(level) { document.execCommand('formatBlock', false, 'H' + level) }
|
||||
function createLink() { const url = prompt('URL eingeben:'); if (url) document.execCommand('createLink', false, url) }
|
||||
function removeFormat() { document.execCommand('removeFormat', false, null) }
|
||||
|
||||
function insertRuleTemplate(type) {
|
||||
const editorElement = editor.value
|
||||
if (!editorElement) return
|
||||
|
||||
const templates = {
|
||||
generic: '<div class="text-center p-6 bg-gray-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Regel</h3><p class="text-gray-600 text-sm">[Regeltext hier eingeben]</p></div>',
|
||||
basic: '<div class="text-center p-6 bg-blue-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Grundregel</h3><p class="text-gray-600 text-sm"><strong>Regel:</strong> [Regeltext hier eingeben]<br><strong>Beschreibung:</strong> [Detaillierte Beschreibung]<br><strong>Anwendung:</strong> [Wann gilt diese Regel?]</p></div>',
|
||||
penalty: '<div class="text-center p-6 bg-green-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Strafregel</h3><p class="text-gray-600 text-sm"><strong>Verstoß:</strong> [Was ist der Verstoß?]<br><strong>Strafe:</strong> [Welche Strafe wird verhängt?]<br><strong>Häufigkeit:</strong> [Bei wiederholten Verstößen?]</p></div>',
|
||||
service: '<div class="text-center p-6 bg-yellow-50 rounded-lg"><h3 class="text-xl font-semibold text-gray-900 mb-2">Neue Aufschlagregel</h3><p class="text-gray-600 text-sm"><strong>Regel:</strong> [Aufschlagregel hier eingeben]<br><strong>Technik:</strong> [Wie muss der Aufschlag ausgeführt werden?]<br><strong>Fehler:</strong> [Was gilt als Fehler?]</p></div>'
|
||||
}
|
||||
|
||||
const template = templates[type] || templates.generic
|
||||
editorElement.focus()
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||
let currentElement = range.commonAncestorContainer
|
||||
if (currentElement.nodeType === Node.TEXT_NODE) currentElement = currentElement.parentElement
|
||||
|
||||
let targetContainer = currentElement
|
||||
while (targetContainer && !targetContainer.classList.contains('grid')) {
|
||||
targetContainer = targetContainer.parentElement
|
||||
}
|
||||
|
||||
if (targetContainer && targetContainer.classList.contains('md:grid-cols-2') && targetContainer.classList.contains('lg:grid-cols-3') && targetContainer.classList.contains('gap-6')) {
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = template
|
||||
let newCard = null
|
||||
for (let i = 0; i < tempDiv.childNodes.length; i++) {
|
||||
if (tempDiv.childNodes[i].nodeType === Node.ELEMENT_NODE) { newCard = tempDiv.childNodes[i]; break }
|
||||
}
|
||||
if (newCard) {
|
||||
targetContainer.appendChild(newCard)
|
||||
const newRange = document.createRange()
|
||||
const titleElement = newCard.querySelector('h3')
|
||||
if (titleElement) { newRange.setStart(titleElement, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange) }
|
||||
}
|
||||
} else {
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
} else {
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
} else {
|
||||
editorElement.innerHTML += template
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCurrentRule() {
|
||||
const editorElement = editor.value
|
||||
if (!editorElement) return
|
||||
editorElement.focus()
|
||||
const selection = window.getSelection()
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
if (editorElement.contains(range.commonAncestorContainer) || editorElement === range.commonAncestorContainer) {
|
||||
let currentElement = range.commonAncestorContainer
|
||||
if (currentElement.nodeType === Node.TEXT_NODE) currentElement = currentElement.parentElement
|
||||
let cardElement = currentElement
|
||||
while (cardElement && !cardElement.classList.contains('text-center')) { cardElement = cardElement.parentElement }
|
||||
if (cardElement && cardElement.classList.contains('text-center')) {
|
||||
cardElement.remove()
|
||||
const gridContainer = editorElement.querySelector('.grid')
|
||||
if (gridContainer && gridContainer.children.length > 0) {
|
||||
const firstCard = gridContainer.firstElementChild
|
||||
const titleElement = firstCard.querySelector('h3')
|
||||
if (titleElement) {
|
||||
const newRange = document.createRange()
|
||||
newRange.setStart(titleElement, 0); newRange.collapse(true)
|
||||
selection.removeAllRanges(); selection.addRange(newRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
133
components/cms/CmsUeberUns.vue
Normal file
133
components/cms/CmsUeberUns.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header with save button -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
|
||||
Über uns bearbeiten
|
||||
</h2>
|
||||
<button
|
||||
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
|
||||
@click="save"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="sticky top-0 z-10 bg-white border border-gray-200 rounded-t-lg shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('bold')"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('italic')"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(1)"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(2)"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="formatHeader(3)"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertUnorderedList')"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="format('insertOrderedList')"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="createLink()"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
|
||||
@click="removeFormat()"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="bg-white rounded-b-lg shadow-sm border border-t-0 border-gray-200 p-3 sm:p-4">
|
||||
<div
|
||||
ref="editor"
|
||||
class="min-h-[320px] p-3 sm:p-4 outline-none prose max-w-none text-sm sm:text-base"
|
||||
contenteditable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const editor = ref(null)
|
||||
|
||||
async function load() {
|
||||
const data = await $fetch('/api/config')
|
||||
const html = data?.seiten?.ueberUns || ''
|
||||
if (editor.value) editor.value.innerHTML = html
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const html = editor.value?.innerHTML || ''
|
||||
const current = await $fetch('/api/config')
|
||||
const updated = { ...current, seiten: { ...(current.seiten || {}), ueberUns: html } }
|
||||
try {
|
||||
await $fetch('/api/config', { method: 'PUT', body: updated })
|
||||
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Inhalt erfolgreich gespeichert!') } catch {
|
||||
// Modal nicht verfügbar
|
||||
}
|
||||
} catch (error) {
|
||||
try { window.showErrorModal && window.showErrorModal('Fehler', error?.data?.message || 'Speichern fehlgeschlagen') } catch {
|
||||
// Modal nicht verfügbar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function format(cmd) {
|
||||
document.execCommand(cmd, false, null)
|
||||
}
|
||||
|
||||
function formatHeader(level) {
|
||||
document.execCommand('formatBlock', false, 'H' + level)
|
||||
}
|
||||
|
||||
function createLink() {
|
||||
const url = prompt('URL eingeben:')
|
||||
if (!url) return
|
||||
document.execCommand('createLink', false, url)
|
||||
}
|
||||
|
||||
function removeFormat() {
|
||||
document.execCommand('removeFormat', false, null)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user